コンテンツにスキップ

APIリファレンス

Wandasライブラリの主要コンポーネントと関数のAPIリファレンスです。

コアモジュール

コアモジュールはWandasの基本的な機能を提供します。

wandas.core

Attributes

__all__ = ['BaseFrame'] module-attribute

Classes

BaseFrame

Bases: ABC, Generic[T]

Abstract base class for all signal frame types.

This class provides the common interface and functionality for all frame types used in signal processing. It implements basic operations like indexing, iteration, and data manipulation that are shared across all frame types.

Parameters

data : DaArray The signal data to process. Must be a dask array. sampling_rate : float The sampling rate of the signal in Hz. label : str, optional A label for the frame. If not provided, defaults to "unnamed_frame". metadata : dict, optional Additional metadata for the frame. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata | dict], optional Metadata for each channel in the frame. Can be ChannelMetadata objects or dicts that will be validated by Pydantic. previous : BaseFrame, optional The frame that this frame was derived from.

Attributes

sampling_rate : float The sampling rate of the signal in Hz. label : str The label of the frame. metadata : dict Additional metadata for the frame. operation_history : list[dict] History of operations performed on this frame.

Source code in wandas/core/base_frame.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
class BaseFrame(ABC, Generic[T]):
    """
    Abstract base class for all signal frame types.

    This class provides the common interface and functionality for all frame types
    used in signal processing. It implements basic operations like indexing, iteration,
    and data manipulation that are shared across all frame types.

    Parameters
    ----------
    data : DaArray
        The signal data to process. Must be a dask array.
    sampling_rate : float
        The sampling rate of the signal in Hz.
    label : str, optional
        A label for the frame. If not provided, defaults to "unnamed_frame".
    metadata : dict, optional
        Additional metadata for the frame.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata | dict], optional
        Metadata for each channel in the frame. Can be ChannelMetadata objects
        or dicts that will be validated by Pydantic.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

    Attributes
    ----------
    sampling_rate : float
        The sampling rate of the signal in Hz.
    label : str
        The label of the frame.
    metadata : dict
        Additional metadata for the frame.
    operation_history : list[dict]
        History of operations performed on this frame.
    """

    _data: DaArray
    sampling_rate: float
    label: str
    metadata: dict[str, Any]
    operation_history: list[dict[str, Any]]
    _channel_metadata: list[ChannelMetadata]
    _previous: Optional["BaseFrame[Any]"]

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ):
        self._data = data.rechunk(chunks=-1)  # type: ignore [unused-ignore]
        if self._data.ndim == 1:
            self._data = self._data.reshape((1, -1))
        self.sampling_rate = sampling_rate
        self.label = label or "unnamed_frame"
        self.metadata = metadata or {}
        self.operation_history = operation_history or []
        self._previous = previous

        if channel_metadata:
            # Pydantic handles both ChannelMetadata objects and dicts
            def _to_channel_metadata(
                ch: ChannelMetadata | dict[str, Any], index: int
            ) -> ChannelMetadata:
                if isinstance(ch, ChannelMetadata):
                    return copy.deepcopy(ch)
                elif isinstance(ch, dict):
                    try:
                        return ChannelMetadata(**ch)
                    except ValidationError as e:
                        raise ValueError(
                            f"Invalid channel_metadata at index {index}\n"
                            f"  Got: {ch}\n"
                            f"  Validation error: {e}\n"
                            f"Ensure all dict keys match ChannelMetadata fields "
                            f"(label, unit, ref, extra) and have correct types."
                        ) from e
                else:
                    raise TypeError(
                        f"Invalid type in channel_metadata at index {index}\n"
                        f"  Got: {type(ch).__name__} ({ch!r})\n"
                        f"  Expected: ChannelMetadata or dict\n"
                        f"Use ChannelMetadata objects or dicts with valid fields."
                    )

            self._channel_metadata = [
                _to_channel_metadata(cast(ChannelMetadata | dict[str, Any], ch), i)
                for i, ch in enumerate(channel_metadata)
            ]
        else:
            self._channel_metadata = [
                ChannelMetadata(label=f"ch{i}", unit="", extra={})
                for i in range(self._n_channels)
            ]

        try:
            # Display information for newer dask versions
            logger.debug(f"Dask graph layers: {list(self._data.dask.layers.keys())}")
            logger.debug(
                f"Dask graph dependencies: {len(self._data.dask.dependencies)}"
            )
        except Exception as e:
            logger.debug(f"Dask graph visualization details unavailable: {e}")

    @property
    @abstractmethod
    def _n_channels(self) -> int:
        """Returns the number of channels."""

    @property
    def n_channels(self) -> int:
        """Returns the number of channels."""
        return self._n_channels

    @property
    def channels(self) -> list[ChannelMetadata]:
        """Property to access channel metadata."""
        return self._channel_metadata

    @property
    def previous(self) -> Optional["BaseFrame[Any]"]:
        """
        Returns the previous frame.
        """
        return self._previous

    def get_channel(
        self: S,
        channel_idx: int
        | list[int]
        | tuple[int, ...]
        | npt.NDArray[np.int_]
        | npt.NDArray[np.bool_],
    ) -> S:
        """
        Get channel(s) by index.

        Parameters
        ----------
        channel_idx : int or sequence of int
            Single channel index or sequence of channel indices.
            Supports negative indices (e.g., -1 for the last channel).

        Returns
        -------
        S
            New instance containing the selected channel(s).

        Examples
        --------
        >>> frame.get_channel(0)  # Single channel
        >>> frame.get_channel([0, 2, 3])  # Multiple channels
        >>> frame.get_channel((-1, -2))  # Last two channels
        >>> frame.get_channel(np.array([1, 2]))  # NumPy array of indices
        """
        if isinstance(channel_idx, int):
            # Convert single channel to a list.
            channel_idx_list: list[int] = [channel_idx]
        else:
            channel_idx_list = list(channel_idx)

        new_data = self._data[channel_idx_list]
        new_channel_metadata = [self._channel_metadata[i] for i in channel_idx_list]
        return self._create_new_instance(
            data=new_data,
            operation_history=self.operation_history,
            channel_metadata=new_channel_metadata,
        )

    def __len__(self) -> int:
        """
        Returns the number of channels.
        """
        return len(self._channel_metadata)

    def __iter__(self: S) -> Iterator[S]:
        for idx in range(len(self)):
            yield self[idx]

    def __getitem__(
        self: S,
        key: int
        | str
        | slice
        | list[int]
        | list[str]
        | tuple[
            int
            | str
            | slice
            | list[int]
            | list[str]
            | npt.NDArray[np.int_]
            | npt.NDArray[np.bool_],
            ...,
        ]
        | npt.NDArray[np.int_]
        | npt.NDArray[np.bool_],
    ) -> S:
        """
        Get channel(s) by index, label, or advanced indexing.

        This method supports multiple indexing patterns similar to NumPy and pandas:

        - Single channel by index: `frame[0]`
        - Single channel by label: `frame["ch0"]`
        - Slice of channels: `frame[0:3]`
        - Multiple channels by indices: `frame[[0, 2, 5]]`
        - Multiple channels by labels: `frame[["ch0", "ch2"]]`
        - NumPy integer array: `frame[np.array([0, 2])]`
        - Boolean mask: `frame[mask]` where mask is a boolean array
        - Multidimensional indexing: `frame[0, 100:200]` (channel + time)

        Parameters
        ----------
        key : int, str, slice, list, tuple, or ndarray
            - int: Single channel index (supports negative indexing)
            - str: Single channel label
            - slice: Range of channels
            - list[int]: Multiple channel indices
            - list[str]: Multiple channel labels
            - tuple: Multidimensional indexing (channel_key, time_key, ...)
            - ndarray[int]: NumPy array of channel indices
            - ndarray[bool]: Boolean mask for channel selection

        Returns
        -------
        S
            New instance containing the selected channel(s).

        Raises
        ------
        ValueError
            If the key length is invalid for the shape or if boolean mask
            length doesn't match number of channels.
        IndexError
            If the channel index is out of range.
        TypeError
            If the key type is invalid or list contains mixed types.
        KeyError
            If a channel label is not found.

        Examples
        --------
        >>> # Single channel selection
        >>> frame[0]  # First channel
        >>> frame["acc_x"]  # By label
        >>> frame[-1]  # Last channel
        >>>
        >>> # Multiple channel selection
        >>> frame[[0, 2, 5]]  # Multiple indices
        >>> frame[["acc_x", "acc_z"]]  # Multiple labels
        >>> frame[0:3]  # Slice
        >>>
        >>> # NumPy array indexing
        >>> frame[np.array([0, 2, 4])]  # Integer array
        >>> mask = np.array([True, False, True])
        >>> frame[mask]  # Boolean mask
        >>>
        >>> # Time slicing (multidimensional)
        >>> frame[0, 100:200]  # Channel 0, samples 100-200
        >>> frame[[0, 1], ::2]  # Channels 0-1, every 2nd sample
        """

        # Single index (int)
        if isinstance(key, numbers.Integral):
            # Ensure we pass a plain Python int to satisfy the type checker
            return self.get_channel(int(key))

        # Single label (str)
        if isinstance(key, str):
            index = self.label2index(key)
            return self.get_channel(index)

        # Phase 2: NumPy array support (bool mask and int array)
        if isinstance(key, np.ndarray):
            if key.dtype == bool or key.dtype == np.bool_:
                # Boolean mask
                if len(key) != self.n_channels:
                    raise ValueError(
                        f"Boolean mask length {len(key)} does not match "
                        f"number of channels {self.n_channels}"
                    )
                indices = np.where(key)[0]
                return self.get_channel(indices)
            elif np.issubdtype(key.dtype, np.integer):
                # Integer array
                return self.get_channel(key)
            else:
                raise TypeError(
                    f"NumPy array must be of integer or boolean type, got {key.dtype}"
                )

        # Phase 1: List support (int or str)
        if isinstance(key, list):
            if len(key) == 0:
                raise ValueError("Cannot index with an empty list")

            # Check if all elements are strings
            if all(isinstance(k, str) for k in key):
                # Multiple labels - type narrowing for mypy
                str_list = cast(list[str], key)
                indices_from_labels = [self.label2index(label) for label in str_list]
                return self.get_channel(indices_from_labels)

            # Check if all elements are integers
            elif all(isinstance(k, int | np.integer) for k in key):
                # Multiple indices - convert to list[int] for type safety
                int_list = [int(k) for k in key]
                return self.get_channel(int_list)

            else:
                raise TypeError(
                    f"List must contain all str or all int, got mixed types: "
                    f"{[type(k).__name__ for k in key]}"
                )

        # Tuple: multidimensional indexing
        if isinstance(key, tuple):
            return self._handle_multidim_indexing(key)

        # Slice
        if isinstance(key, slice):
            new_data = self._data[key]
            new_channel_metadata = self._channel_metadata[key]
            if isinstance(new_channel_metadata, ChannelMetadata):
                new_channel_metadata = [new_channel_metadata]
            return self._create_new_instance(
                data=new_data,
                operation_history=self.operation_history,
                channel_metadata=new_channel_metadata,
            )

        raise TypeError(
            f"Invalid key type: {type(key).__name__}. "
            f"Expected int, str, slice, list, tuple, or ndarray."
        )

    def _handle_multidim_indexing(
        self: S,
        key: tuple[
            int
            | str
            | slice
            | list[int]
            | list[str]
            | npt.NDArray[np.int_]
            | npt.NDArray[np.bool_],
            ...,
        ],
    ) -> S:
        """
        Handle multidimensional indexing (channel + time axis).

        Parameters
        ----------
        key : tuple
            Tuple of indices where the first element selects channels
            and subsequent elements select along other dimensions (e.g., time).

        Returns
        -------
        S
            New instance with selected channels and time range.

        Raises
        ------
        ValueError
            If the key length exceeds the data dimensions.
        """
        if len(key) > self._data.ndim:
            raise ValueError(f"Invalid key length: {len(key)} for shape {self.shape}")

        # First element: channel selection
        channel_key = key[0]
        time_keys = key[1:] if len(key) > 1 else ()

        # Select channels first (recursively call __getitem__)
        if isinstance(channel_key, list | np.ndarray):
            selected = self[channel_key]
        elif isinstance(channel_key, int | str | slice):
            selected = self[channel_key]
        else:
            raise TypeError(
                f"Invalid channel key type in tuple: {type(channel_key).__name__}"
            )

        # Apply time indexing if present
        if time_keys:
            new_data = selected._data[(slice(None),) + time_keys]
            return selected._create_new_instance(
                data=new_data,
                operation_history=selected.operation_history,
                channel_metadata=selected._channel_metadata,
            )

        return selected

    def label2index(self, label: str) -> int:
        """
        Get the index from a channel label.

        Parameters
        ----------
        label : str
            Channel label.

        Returns
        -------
        int
            Corresponding index.

        Raises
        ------
        KeyError
            If the channel label is not found.
        """
        for idx, ch in enumerate(self._channel_metadata):
            if ch.label == label:
                return idx
        raise KeyError(f"Channel label '{label}' not found.")

    @property
    def shape(self) -> tuple[int, ...]:
        _shape: tuple[int, ...] = self._data.shape
        if _shape[0] == 1:
            return _shape[1:]
        return _shape

    @property
    def data(self) -> T:
        """
        Returns the computed data.
        Calculation is executed the first time this is accessed.
        """
        data = self.compute()
        if self.n_channels == 1:
            return data.squeeze(axis=0)
        return data

    @property
    def labels(self) -> list[str]:
        """Get a list of all channel labels."""
        return [ch.label for ch in self._channel_metadata]

    def compute(self) -> T:
        """
        Compute and return the data.
        This method materializes lazily computed data into a concrete NumPy array.

        Returns
        -------
        NDArrayReal
            The computed data.

        Raises
        ------
        ValueError
            If the computed result is not a NumPy array.
        """
        logger.debug(
            "COMPUTING DASK ARRAY - This will trigger file reading and all processing"
        )
        result = self._data.compute()

        if not isinstance(result, np.ndarray):
            raise ValueError(f"Computed result is not a np.ndarray: {type(result)}")

        logger.debug(f"Computation complete, result shape: {result.shape}")
        return cast(T, result)

    @abstractmethod
    def plot(
        self, plot_type: str = "default", ax: Axes | None = None, **kwargs: Any
    ) -> Axes | Iterator[Axes]:
        """Plot the data"""
        pass

    def persist(self: S) -> S:
        """Persist the data in memory"""
        persisted_data = self._data.persist()
        return self._create_new_instance(data=persisted_data)

    @abstractmethod
    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Abstract method for derived classes to provide
        additional initialization arguments.
        """
        pass

    def _create_new_instance(self: S, data: DaArray, **kwargs: Any) -> S:
        """
        Create a new channel instance based on an existing channel.
        Keyword arguments can override or extend the original attributes.
        """

        sampling_rate = kwargs.pop("sampling_rate", self.sampling_rate)
        # if not isinstance(sampling_rate, int):
        #     raise TypeError("Sampling rate must be an integer")

        label = kwargs.pop("label", self.label)
        if not isinstance(label, str):
            raise TypeError("Label must be a string")

        metadata = kwargs.pop("metadata", copy.deepcopy(self.metadata))
        if not isinstance(metadata, dict):
            raise TypeError("Metadata must be a dictionary")

        channel_metadata = kwargs.pop(
            "channel_metadata", copy.deepcopy(self._channel_metadata)
        )
        if not isinstance(channel_metadata, list):
            raise TypeError("Channel metadata must be a list")

        # Get additional initialization arguments from derived classes
        additional_kwargs = self._get_additional_init_kwargs()
        kwargs.update(additional_kwargs)

        return type(self)(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            previous=self,
            **kwargs,
        )

    def __array__(self, dtype: npt.DTypeLike = None) -> NDArrayReal:
        """Implicit conversion to NumPy array"""
        result = self.compute()
        if dtype is not None:
            return result.astype(dtype)
        return result

    def visualize_graph(self, filename: str | None = None) -> IPythonImage | None:
        """
        Visualize the computation graph and save it to a file.

        This method creates a visual representation of the Dask computation graph.
        In Jupyter notebooks, it returns an IPython.display.Image object that
        will be displayed inline. In other environments, it saves the graph to
        a file and returns None.

        Parameters
        ----------
        filename : str, optional
            Output filename for the graph image. If None, a unique filename
            is generated using UUID. The file is saved in the current working
            directory.

        Returns
        -------
        IPython.display.Image or None
            In Jupyter environments: Returns an IPython.display.Image object
            that can be displayed inline.
            In other environments: Returns None after saving the graph to file.
            Returns None if visualization fails.

        Notes
        -----
        This method requires graphviz to be installed on your system:
        - Ubuntu/Debian: `sudo apt-get install graphviz`
        - macOS: `brew install graphviz`
        - Windows: Download from https://graphviz.org/download/

        The graph displays operation names (e.g., 'normalize', 'lowpass_filter')
        making it easier to understand the processing pipeline.

        Examples
        --------
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> processed = signal.normalize().low_pass_filter(cutoff=1000)
        >>> # In Jupyter: displays graph inline
        >>> processed.visualize_graph()
        >>> # Save to specific file
        >>> processed.visualize_graph("my_graph.png")

        See Also
        --------
        debug_info : Print detailed debug information about the frame
        """
        try:
            filename = filename or f"graph_{uuid.uuid4().hex[:8]}.png"
            return self._data.visualize(filename=filename)
        except Exception as e:
            logger.warning(f"Failed to visualize the graph: {e}")
            return None

    @abstractmethod
    def _binary_op(
        self: S,
        other: S | int | float | NDArrayReal | DaArray,
        op: Callable[[DaArray, Any], DaArray],
        symbol: str,
    ) -> S:
        """Basic implementation of binary operations"""
        # Basic logic
        # Actual implementation is left to derived classes
        pass

    def __add__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Addition operator"""
        return self._binary_op(other, lambda x, y: x + y, "+")

    def __sub__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Subtraction operator"""
        return self._binary_op(other, lambda x, y: x - y, "-")

    def __mul__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Multiplication operator"""
        return self._binary_op(other, lambda x, y: x * y, "*")

    def __truediv__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Division operator"""
        return self._binary_op(other, lambda x, y: x / y, "/")

    def __pow__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Power operator"""
        return self._binary_op(other, lambda x, y: x**y, "**")

    def apply_operation(self: S, operation_name: str, **params: Any) -> S:
        """
        Apply a named operation.

        Parameters
        ----------
        operation_name : str
            Name of the operation to apply.
        **params : Any
            Parameters to pass to the operation.

        Returns
        -------
        S
            A new instance with the operation applied.
        """
        # Apply the operation through abstract method
        return self._apply_operation_impl(operation_name, **params)

    @abstractmethod
    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        """Implementation of operation application"""
        pass

    def _relabel_channels(
        self,
        operation_name: str,
        display_name: str | None = None,
    ) -> list[ChannelMetadata]:
        """
        Update channel labels to reflect applied operation.

        This method creates new channel metadata with labels that include
        the operation name, making it easier to track processing history
        and distinguish frames in plots.

        Parameters
        ----------
        operation_name : str
            Name of the operation (e.g., "normalize", "lowpass_filter")
        display_name : str, optional
            Display name for the operation. If None, uses operation_name.
            This allows operations to provide custom, more readable labels.

        Returns
        -------
        list[ChannelMetadata]
            New channel metadata with updated labels.
            Original metadata is deep-copied and only labels are modified.

        Examples
        --------
        >>> # Original label: "ch0"
        >>> # After normalize: "normalize(ch0)"
        >>> # After chained ops: "lowpass_filter(normalize(ch0))"

        Notes
        -----
        Labels are nested for chained operations, allowing full
        traceability of the processing pipeline.
        """
        display = display_name or operation_name
        new_metadata = []
        for ch in self._channel_metadata:
            # All channel metadata are ChannelMetadata objects at this point
            new_ch = ch.model_copy(deep=True)
            new_ch.label = f"{display}({ch.label})"
            new_metadata.append(new_ch)
        return new_metadata

    def debug_info(self) -> None:
        """Output detailed debug information"""
        logger.debug(f"=== {self.__class__.__name__} Debug Info ===")
        logger.debug(f"Label: {self.label}")
        logger.debug(f"Shape: {self.shape}")
        logger.debug(f"Sampling rate: {self.sampling_rate} Hz")
        logger.debug(f"Operation history: {len(self.operation_history)} operations")
        self._debug_info_impl()
        logger.debug("=== End Debug Info ===")

    def print_operation_history(self) -> None:
        """
        Print the operation history to standard output in a readable format.

        This method writes a human-friendly representation of the
        `operation_history` list to stdout. Each operation is printed on its
        own line with an index, the operation name (if available), and the
        parameters used.

        Examples
        --------
        >>> cf.print_operation_history()
        1: normalize {}
        2: low_pass_filter {'cutoff': 1000}
        """
        if not self.operation_history:
            print("Operation history: <empty>")
            return

        print(f"Operation history ({len(self.operation_history)}):")
        for i, record in enumerate(self.operation_history, start=1):
            # record is expected to be a dict with at least a 'operation' key
            op_name = record.get("operation") or record.get("name") or "<unknown>"
            # Copy params for display - exclude the 'operation'/'name' keys
            params = {k: v for k, v in record.items() if k not in ("operation", "name")}
            print(f"{i}: {op_name} {params}")

    def to_numpy(self) -> T:
        """Convert the frame data to a NumPy array.

        This method computes the Dask array and returns it as a concrete NumPy array.
        The returned array has the same shape as the frame's data.

        Returns
        -------
        T
            NumPy array containing the frame data.

        Examples
        --------
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> data = cf.to_numpy()
        >>> print(f"Shape: {data.shape}")  # (n_channels, n_samples)
        """
        return self.data

    def to_dataframe(self) -> "pd.DataFrame":
        """Convert the frame data to a pandas DataFrame.

        This method provides a common implementation for converting frame data
        to pandas DataFrame. Subclasses can override this method for custom behavior.

        Returns
        -------
        pd.DataFrame
            DataFrame with appropriate index and columns.

        Examples
        --------
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> df = cf.to_dataframe()
        >>> print(df.head())
        """
        # Get data as numpy array
        data = self.to_numpy()

        # Get column names from subclass
        columns = self._get_dataframe_columns()

        # Get index from subclass
        index = self._get_dataframe_index()

        # Create DataFrame
        if data.ndim == 1:
            # Single channel case - reshape to 2D
            df = pd.DataFrame(data.reshape(-1, 1), columns=columns, index=index)
        else:
            # Multi-channel case - transpose to (n_samples, n_channels)
            df = pd.DataFrame(data.T, columns=columns, index=index)

        return df

    @abstractmethod
    def _get_dataframe_columns(self) -> list[str]:
        """Get column names for DataFrame.

        This method should be implemented by subclasses to provide
        appropriate column names for the DataFrame.

        Returns
        -------
        list[str]
            List of column names.
        """
        pass

    @abstractmethod
    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get index for DataFrame.

        This method should be implemented by subclasses to provide
        appropriate index for the DataFrame based on the frame type.

        Returns
        -------
        pd.Index
            Index for the DataFrame.
        """
        pass

    def _debug_info_impl(self) -> None:
        """Implement derived class-specific debug information"""
        pass

    def _print_operation_history(self) -> None:
        """Print the operation history information.

        This is a helper method for info() implementations to display
        the number of operations applied to the frame in a consistent format.
        """
        if self.operation_history:
            print(f"  Operations Applied: {len(self.operation_history)}")
        else:
            print("  Operations Applied: None")
Attributes
sampling_rate = sampling_rate instance-attribute
label = label or 'unnamed_frame' instance-attribute
metadata = metadata or {} instance-attribute
operation_history = operation_history or [] instance-attribute
n_channels property

Returns the number of channels.

channels property

Property to access channel metadata.

previous property

Returns the previous frame.

shape property
data property

Returns the computed data. Calculation is executed the first time this is accessed.

labels property

Get a list of all channel labels.

Functions
__init__(data, sampling_rate, label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)
Source code in wandas/core/base_frame.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
):
    self._data = data.rechunk(chunks=-1)  # type: ignore [unused-ignore]
    if self._data.ndim == 1:
        self._data = self._data.reshape((1, -1))
    self.sampling_rate = sampling_rate
    self.label = label or "unnamed_frame"
    self.metadata = metadata or {}
    self.operation_history = operation_history or []
    self._previous = previous

    if channel_metadata:
        # Pydantic handles both ChannelMetadata objects and dicts
        def _to_channel_metadata(
            ch: ChannelMetadata | dict[str, Any], index: int
        ) -> ChannelMetadata:
            if isinstance(ch, ChannelMetadata):
                return copy.deepcopy(ch)
            elif isinstance(ch, dict):
                try:
                    return ChannelMetadata(**ch)
                except ValidationError as e:
                    raise ValueError(
                        f"Invalid channel_metadata at index {index}\n"
                        f"  Got: {ch}\n"
                        f"  Validation error: {e}\n"
                        f"Ensure all dict keys match ChannelMetadata fields "
                        f"(label, unit, ref, extra) and have correct types."
                    ) from e
            else:
                raise TypeError(
                    f"Invalid type in channel_metadata at index {index}\n"
                    f"  Got: {type(ch).__name__} ({ch!r})\n"
                    f"  Expected: ChannelMetadata or dict\n"
                    f"Use ChannelMetadata objects or dicts with valid fields."
                )

        self._channel_metadata = [
            _to_channel_metadata(cast(ChannelMetadata | dict[str, Any], ch), i)
            for i, ch in enumerate(channel_metadata)
        ]
    else:
        self._channel_metadata = [
            ChannelMetadata(label=f"ch{i}", unit="", extra={})
            for i in range(self._n_channels)
        ]

    try:
        # Display information for newer dask versions
        logger.debug(f"Dask graph layers: {list(self._data.dask.layers.keys())}")
        logger.debug(
            f"Dask graph dependencies: {len(self._data.dask.dependencies)}"
        )
    except Exception as e:
        logger.debug(f"Dask graph visualization details unavailable: {e}")
get_channel(channel_idx)

Get channel(s) by index.

Parameters

channel_idx : int or sequence of int Single channel index or sequence of channel indices. Supports negative indices (e.g., -1 for the last channel).

Returns

S New instance containing the selected channel(s).

Examples

frame.get_channel(0) # Single channel frame.get_channel([0, 2, 3]) # Multiple channels frame.get_channel((-1, -2)) # Last two channels frame.get_channel(np.array([1, 2])) # NumPy array of indices

Source code in wandas/core/base_frame.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def get_channel(
    self: S,
    channel_idx: int
    | list[int]
    | tuple[int, ...]
    | npt.NDArray[np.int_]
    | npt.NDArray[np.bool_],
) -> S:
    """
    Get channel(s) by index.

    Parameters
    ----------
    channel_idx : int or sequence of int
        Single channel index or sequence of channel indices.
        Supports negative indices (e.g., -1 for the last channel).

    Returns
    -------
    S
        New instance containing the selected channel(s).

    Examples
    --------
    >>> frame.get_channel(0)  # Single channel
    >>> frame.get_channel([0, 2, 3])  # Multiple channels
    >>> frame.get_channel((-1, -2))  # Last two channels
    >>> frame.get_channel(np.array([1, 2]))  # NumPy array of indices
    """
    if isinstance(channel_idx, int):
        # Convert single channel to a list.
        channel_idx_list: list[int] = [channel_idx]
    else:
        channel_idx_list = list(channel_idx)

    new_data = self._data[channel_idx_list]
    new_channel_metadata = [self._channel_metadata[i] for i in channel_idx_list]
    return self._create_new_instance(
        data=new_data,
        operation_history=self.operation_history,
        channel_metadata=new_channel_metadata,
    )
__len__()

Returns the number of channels.

Source code in wandas/core/base_frame.py
202
203
204
205
206
def __len__(self) -> int:
    """
    Returns the number of channels.
    """
    return len(self._channel_metadata)
__iter__()
Source code in wandas/core/base_frame.py
208
209
210
def __iter__(self: S) -> Iterator[S]:
    for idx in range(len(self)):
        yield self[idx]
__getitem__(key)

Get channel(s) by index, label, or advanced indexing.

This method supports multiple indexing patterns similar to NumPy and pandas:

  • Single channel by index: frame[0]
  • Single channel by label: frame["ch0"]
  • Slice of channels: frame[0:3]
  • Multiple channels by indices: frame[[0, 2, 5]]
  • Multiple channels by labels: frame[["ch0", "ch2"]]
  • NumPy integer array: frame[np.array([0, 2])]
  • Boolean mask: frame[mask] where mask is a boolean array
  • Multidimensional indexing: frame[0, 100:200] (channel + time)
Parameters

key : int, str, slice, list, tuple, or ndarray - int: Single channel index (supports negative indexing) - str: Single channel label - slice: Range of channels - list[int]: Multiple channel indices - list[str]: Multiple channel labels - tuple: Multidimensional indexing (channel_key, time_key, ...) - ndarray[int]: NumPy array of channel indices - ndarray[bool]: Boolean mask for channel selection

Returns

S New instance containing the selected channel(s).

Raises

ValueError If the key length is invalid for the shape or if boolean mask length doesn't match number of channels. IndexError If the channel index is out of range. TypeError If the key type is invalid or list contains mixed types. KeyError If a channel label is not found.

Examples
Single channel selection

frame[0] # First channel frame["acc_x"] # By label frame[-1] # Last channel

Multiple channel selection

frame[[0, 2, 5]] # Multiple indices frame[["acc_x", "acc_z"]] # Multiple labels frame[0:3] # Slice

NumPy array indexing

frame[np.array([0, 2, 4])] # Integer array mask = np.array([True, False, True]) frame[mask] # Boolean mask

Time slicing (multidimensional)

frame[0, 100:200] # Channel 0, samples 100-200 frame[[0, 1], ::2] # Channels 0-1, every 2nd sample

Source code in wandas/core/base_frame.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def __getitem__(
    self: S,
    key: int
    | str
    | slice
    | list[int]
    | list[str]
    | tuple[
        int
        | str
        | slice
        | list[int]
        | list[str]
        | npt.NDArray[np.int_]
        | npt.NDArray[np.bool_],
        ...,
    ]
    | npt.NDArray[np.int_]
    | npt.NDArray[np.bool_],
) -> S:
    """
    Get channel(s) by index, label, or advanced indexing.

    This method supports multiple indexing patterns similar to NumPy and pandas:

    - Single channel by index: `frame[0]`
    - Single channel by label: `frame["ch0"]`
    - Slice of channels: `frame[0:3]`
    - Multiple channels by indices: `frame[[0, 2, 5]]`
    - Multiple channels by labels: `frame[["ch0", "ch2"]]`
    - NumPy integer array: `frame[np.array([0, 2])]`
    - Boolean mask: `frame[mask]` where mask is a boolean array
    - Multidimensional indexing: `frame[0, 100:200]` (channel + time)

    Parameters
    ----------
    key : int, str, slice, list, tuple, or ndarray
        - int: Single channel index (supports negative indexing)
        - str: Single channel label
        - slice: Range of channels
        - list[int]: Multiple channel indices
        - list[str]: Multiple channel labels
        - tuple: Multidimensional indexing (channel_key, time_key, ...)
        - ndarray[int]: NumPy array of channel indices
        - ndarray[bool]: Boolean mask for channel selection

    Returns
    -------
    S
        New instance containing the selected channel(s).

    Raises
    ------
    ValueError
        If the key length is invalid for the shape or if boolean mask
        length doesn't match number of channels.
    IndexError
        If the channel index is out of range.
    TypeError
        If the key type is invalid or list contains mixed types.
    KeyError
        If a channel label is not found.

    Examples
    --------
    >>> # Single channel selection
    >>> frame[0]  # First channel
    >>> frame["acc_x"]  # By label
    >>> frame[-1]  # Last channel
    >>>
    >>> # Multiple channel selection
    >>> frame[[0, 2, 5]]  # Multiple indices
    >>> frame[["acc_x", "acc_z"]]  # Multiple labels
    >>> frame[0:3]  # Slice
    >>>
    >>> # NumPy array indexing
    >>> frame[np.array([0, 2, 4])]  # Integer array
    >>> mask = np.array([True, False, True])
    >>> frame[mask]  # Boolean mask
    >>>
    >>> # Time slicing (multidimensional)
    >>> frame[0, 100:200]  # Channel 0, samples 100-200
    >>> frame[[0, 1], ::2]  # Channels 0-1, every 2nd sample
    """

    # Single index (int)
    if isinstance(key, numbers.Integral):
        # Ensure we pass a plain Python int to satisfy the type checker
        return self.get_channel(int(key))

    # Single label (str)
    if isinstance(key, str):
        index = self.label2index(key)
        return self.get_channel(index)

    # Phase 2: NumPy array support (bool mask and int array)
    if isinstance(key, np.ndarray):
        if key.dtype == bool or key.dtype == np.bool_:
            # Boolean mask
            if len(key) != self.n_channels:
                raise ValueError(
                    f"Boolean mask length {len(key)} does not match "
                    f"number of channels {self.n_channels}"
                )
            indices = np.where(key)[0]
            return self.get_channel(indices)
        elif np.issubdtype(key.dtype, np.integer):
            # Integer array
            return self.get_channel(key)
        else:
            raise TypeError(
                f"NumPy array must be of integer or boolean type, got {key.dtype}"
            )

    # Phase 1: List support (int or str)
    if isinstance(key, list):
        if len(key) == 0:
            raise ValueError("Cannot index with an empty list")

        # Check if all elements are strings
        if all(isinstance(k, str) for k in key):
            # Multiple labels - type narrowing for mypy
            str_list = cast(list[str], key)
            indices_from_labels = [self.label2index(label) for label in str_list]
            return self.get_channel(indices_from_labels)

        # Check if all elements are integers
        elif all(isinstance(k, int | np.integer) for k in key):
            # Multiple indices - convert to list[int] for type safety
            int_list = [int(k) for k in key]
            return self.get_channel(int_list)

        else:
            raise TypeError(
                f"List must contain all str or all int, got mixed types: "
                f"{[type(k).__name__ for k in key]}"
            )

    # Tuple: multidimensional indexing
    if isinstance(key, tuple):
        return self._handle_multidim_indexing(key)

    # Slice
    if isinstance(key, slice):
        new_data = self._data[key]
        new_channel_metadata = self._channel_metadata[key]
        if isinstance(new_channel_metadata, ChannelMetadata):
            new_channel_metadata = [new_channel_metadata]
        return self._create_new_instance(
            data=new_data,
            operation_history=self.operation_history,
            channel_metadata=new_channel_metadata,
        )

    raise TypeError(
        f"Invalid key type: {type(key).__name__}. "
        f"Expected int, str, slice, list, tuple, or ndarray."
    )
label2index(label)

Get the index from a channel label.

Parameters

label : str Channel label.

Returns

int Corresponding index.

Raises

KeyError If the channel label is not found.

Source code in wandas/core/base_frame.py
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def label2index(self, label: str) -> int:
    """
    Get the index from a channel label.

    Parameters
    ----------
    label : str
        Channel label.

    Returns
    -------
    int
        Corresponding index.

    Raises
    ------
    KeyError
        If the channel label is not found.
    """
    for idx, ch in enumerate(self._channel_metadata):
        if ch.label == label:
            return idx
    raise KeyError(f"Channel label '{label}' not found.")
compute()

Compute and return the data. This method materializes lazily computed data into a concrete NumPy array.

Returns

NDArrayReal The computed data.

Raises

ValueError If the computed result is not a NumPy array.

Source code in wandas/core/base_frame.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
def compute(self) -> T:
    """
    Compute and return the data.
    This method materializes lazily computed data into a concrete NumPy array.

    Returns
    -------
    NDArrayReal
        The computed data.

    Raises
    ------
    ValueError
        If the computed result is not a NumPy array.
    """
    logger.debug(
        "COMPUTING DASK ARRAY - This will trigger file reading and all processing"
    )
    result = self._data.compute()

    if not isinstance(result, np.ndarray):
        raise ValueError(f"Computed result is not a np.ndarray: {type(result)}")

    logger.debug(f"Computation complete, result shape: {result.shape}")
    return cast(T, result)
plot(plot_type='default', ax=None, **kwargs) abstractmethod

Plot the data

Source code in wandas/core/base_frame.py
504
505
506
507
508
509
@abstractmethod
def plot(
    self, plot_type: str = "default", ax: Axes | None = None, **kwargs: Any
) -> Axes | Iterator[Axes]:
    """Plot the data"""
    pass
persist()

Persist the data in memory

Source code in wandas/core/base_frame.py
511
512
513
514
def persist(self: S) -> S:
    """Persist the data in memory"""
    persisted_data = self._data.persist()
    return self._create_new_instance(data=persisted_data)
__array__(dtype=None)

Implicit conversion to NumPy array

Source code in wandas/core/base_frame.py
562
563
564
565
566
567
def __array__(self, dtype: npt.DTypeLike = None) -> NDArrayReal:
    """Implicit conversion to NumPy array"""
    result = self.compute()
    if dtype is not None:
        return result.astype(dtype)
    return result
visualize_graph(filename=None)

Visualize the computation graph and save it to a file.

This method creates a visual representation of the Dask computation graph. In Jupyter notebooks, it returns an IPython.display.Image object that will be displayed inline. In other environments, it saves the graph to a file and returns None.

Parameters

filename : str, optional Output filename for the graph image. If None, a unique filename is generated using UUID. The file is saved in the current working directory.

Returns

IPython.display.Image or None In Jupyter environments: Returns an IPython.display.Image object that can be displayed inline. In other environments: Returns None after saving the graph to file. Returns None if visualization fails.

Notes

This method requires graphviz to be installed on your system: - Ubuntu/Debian: sudo apt-get install graphviz - macOS: brew install graphviz - Windows: Download from https://graphviz.org/download/

The graph displays operation names (e.g., 'normalize', 'lowpass_filter') making it easier to understand the processing pipeline.

Examples

import wandas as wd signal = wd.read_wav("audio.wav") processed = signal.normalize().low_pass_filter(cutoff=1000)

In Jupyter: displays graph inline

processed.visualize_graph()

Save to specific file

processed.visualize_graph("my_graph.png")

See Also

debug_info : Print detailed debug information about the frame

Source code in wandas/core/base_frame.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
def visualize_graph(self, filename: str | None = None) -> IPythonImage | None:
    """
    Visualize the computation graph and save it to a file.

    This method creates a visual representation of the Dask computation graph.
    In Jupyter notebooks, it returns an IPython.display.Image object that
    will be displayed inline. In other environments, it saves the graph to
    a file and returns None.

    Parameters
    ----------
    filename : str, optional
        Output filename for the graph image. If None, a unique filename
        is generated using UUID. The file is saved in the current working
        directory.

    Returns
    -------
    IPython.display.Image or None
        In Jupyter environments: Returns an IPython.display.Image object
        that can be displayed inline.
        In other environments: Returns None after saving the graph to file.
        Returns None if visualization fails.

    Notes
    -----
    This method requires graphviz to be installed on your system:
    - Ubuntu/Debian: `sudo apt-get install graphviz`
    - macOS: `brew install graphviz`
    - Windows: Download from https://graphviz.org/download/

    The graph displays operation names (e.g., 'normalize', 'lowpass_filter')
    making it easier to understand the processing pipeline.

    Examples
    --------
    >>> import wandas as wd
    >>> signal = wd.read_wav("audio.wav")
    >>> processed = signal.normalize().low_pass_filter(cutoff=1000)
    >>> # In Jupyter: displays graph inline
    >>> processed.visualize_graph()
    >>> # Save to specific file
    >>> processed.visualize_graph("my_graph.png")

    See Also
    --------
    debug_info : Print detailed debug information about the frame
    """
    try:
        filename = filename or f"graph_{uuid.uuid4().hex[:8]}.png"
        return self._data.visualize(filename=filename)
    except Exception as e:
        logger.warning(f"Failed to visualize the graph: {e}")
        return None
__add__(other)

Addition operator

Source code in wandas/core/base_frame.py
636
637
638
def __add__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Addition operator"""
    return self._binary_op(other, lambda x, y: x + y, "+")
__sub__(other)

Subtraction operator

Source code in wandas/core/base_frame.py
640
641
642
def __sub__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Subtraction operator"""
    return self._binary_op(other, lambda x, y: x - y, "-")
__mul__(other)

Multiplication operator

Source code in wandas/core/base_frame.py
644
645
646
def __mul__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Multiplication operator"""
    return self._binary_op(other, lambda x, y: x * y, "*")
__truediv__(other)

Division operator

Source code in wandas/core/base_frame.py
648
649
650
def __truediv__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Division operator"""
    return self._binary_op(other, lambda x, y: x / y, "/")
__pow__(other)

Power operator

Source code in wandas/core/base_frame.py
652
653
654
def __pow__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Power operator"""
    return self._binary_op(other, lambda x, y: x**y, "**")
apply_operation(operation_name, **params)

Apply a named operation.

Parameters

operation_name : str Name of the operation to apply. **params : Any Parameters to pass to the operation.

Returns

S A new instance with the operation applied.

Source code in wandas/core/base_frame.py
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
def apply_operation(self: S, operation_name: str, **params: Any) -> S:
    """
    Apply a named operation.

    Parameters
    ----------
    operation_name : str
        Name of the operation to apply.
    **params : Any
        Parameters to pass to the operation.

    Returns
    -------
    S
        A new instance with the operation applied.
    """
    # Apply the operation through abstract method
    return self._apply_operation_impl(operation_name, **params)
debug_info()

Output detailed debug information

Source code in wandas/core/base_frame.py
726
727
728
729
730
731
732
733
734
def debug_info(self) -> None:
    """Output detailed debug information"""
    logger.debug(f"=== {self.__class__.__name__} Debug Info ===")
    logger.debug(f"Label: {self.label}")
    logger.debug(f"Shape: {self.shape}")
    logger.debug(f"Sampling rate: {self.sampling_rate} Hz")
    logger.debug(f"Operation history: {len(self.operation_history)} operations")
    self._debug_info_impl()
    logger.debug("=== End Debug Info ===")
print_operation_history()

Print the operation history to standard output in a readable format.

This method writes a human-friendly representation of the operation_history list to stdout. Each operation is printed on its own line with an index, the operation name (if available), and the parameters used.

Examples

cf.print_operation_history() 1: normalize {} 2: low_pass_filter {'cutoff': 1000}

Source code in wandas/core/base_frame.py
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
def print_operation_history(self) -> None:
    """
    Print the operation history to standard output in a readable format.

    This method writes a human-friendly representation of the
    `operation_history` list to stdout. Each operation is printed on its
    own line with an index, the operation name (if available), and the
    parameters used.

    Examples
    --------
    >>> cf.print_operation_history()
    1: normalize {}
    2: low_pass_filter {'cutoff': 1000}
    """
    if not self.operation_history:
        print("Operation history: <empty>")
        return

    print(f"Operation history ({len(self.operation_history)}):")
    for i, record in enumerate(self.operation_history, start=1):
        # record is expected to be a dict with at least a 'operation' key
        op_name = record.get("operation") or record.get("name") or "<unknown>"
        # Copy params for display - exclude the 'operation'/'name' keys
        params = {k: v for k, v in record.items() if k not in ("operation", "name")}
        print(f"{i}: {op_name} {params}")
to_numpy()

Convert the frame data to a NumPy array.

This method computes the Dask array and returns it as a concrete NumPy array. The returned array has the same shape as the frame's data.

Returns

T NumPy array containing the frame data.

Examples

cf = ChannelFrame.read_wav("audio.wav") data = cf.to_numpy() print(f"Shape: {data.shape}") # (n_channels, n_samples)

Source code in wandas/core/base_frame.py
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
def to_numpy(self) -> T:
    """Convert the frame data to a NumPy array.

    This method computes the Dask array and returns it as a concrete NumPy array.
    The returned array has the same shape as the frame's data.

    Returns
    -------
    T
        NumPy array containing the frame data.

    Examples
    --------
    >>> cf = ChannelFrame.read_wav("audio.wav")
    >>> data = cf.to_numpy()
    >>> print(f"Shape: {data.shape}")  # (n_channels, n_samples)
    """
    return self.data
to_dataframe()

Convert the frame data to a pandas DataFrame.

This method provides a common implementation for converting frame data to pandas DataFrame. Subclasses can override this method for custom behavior.

Returns

pd.DataFrame DataFrame with appropriate index and columns.

Examples

cf = ChannelFrame.read_wav("audio.wav") df = cf.to_dataframe() print(df.head())

Source code in wandas/core/base_frame.py
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
def to_dataframe(self) -> "pd.DataFrame":
    """Convert the frame data to a pandas DataFrame.

    This method provides a common implementation for converting frame data
    to pandas DataFrame. Subclasses can override this method for custom behavior.

    Returns
    -------
    pd.DataFrame
        DataFrame with appropriate index and columns.

    Examples
    --------
    >>> cf = ChannelFrame.read_wav("audio.wav")
    >>> df = cf.to_dataframe()
    >>> print(df.head())
    """
    # Get data as numpy array
    data = self.to_numpy()

    # Get column names from subclass
    columns = self._get_dataframe_columns()

    # Get index from subclass
    index = self._get_dataframe_index()

    # Create DataFrame
    if data.ndim == 1:
        # Single channel case - reshape to 2D
        df = pd.DataFrame(data.reshape(-1, 1), columns=columns, index=index)
    else:
        # Multi-channel case - transpose to (n_samples, n_channels)
        df = pd.DataFrame(data.T, columns=columns, index=index)

    return df

Modules

base_frame

Attributes
logger = logging.getLogger(__name__) module-attribute
T = TypeVar('T', NDArrayComplex, NDArrayReal) module-attribute
S = TypeVar('S', bound='BaseFrame[Any]') module-attribute
Classes
BaseFrame

Bases: ABC, Generic[T]

Abstract base class for all signal frame types.

This class provides the common interface and functionality for all frame types used in signal processing. It implements basic operations like indexing, iteration, and data manipulation that are shared across all frame types.

Parameters

data : DaArray The signal data to process. Must be a dask array. sampling_rate : float The sampling rate of the signal in Hz. label : str, optional A label for the frame. If not provided, defaults to "unnamed_frame". metadata : dict, optional Additional metadata for the frame. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata | dict], optional Metadata for each channel in the frame. Can be ChannelMetadata objects or dicts that will be validated by Pydantic. previous : BaseFrame, optional The frame that this frame was derived from.

Attributes

sampling_rate : float The sampling rate of the signal in Hz. label : str The label of the frame. metadata : dict Additional metadata for the frame. operation_history : list[dict] History of operations performed on this frame.

Source code in wandas/core/base_frame.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
class BaseFrame(ABC, Generic[T]):
    """
    Abstract base class for all signal frame types.

    This class provides the common interface and functionality for all frame types
    used in signal processing. It implements basic operations like indexing, iteration,
    and data manipulation that are shared across all frame types.

    Parameters
    ----------
    data : DaArray
        The signal data to process. Must be a dask array.
    sampling_rate : float
        The sampling rate of the signal in Hz.
    label : str, optional
        A label for the frame. If not provided, defaults to "unnamed_frame".
    metadata : dict, optional
        Additional metadata for the frame.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata | dict], optional
        Metadata for each channel in the frame. Can be ChannelMetadata objects
        or dicts that will be validated by Pydantic.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

    Attributes
    ----------
    sampling_rate : float
        The sampling rate of the signal in Hz.
    label : str
        The label of the frame.
    metadata : dict
        Additional metadata for the frame.
    operation_history : list[dict]
        History of operations performed on this frame.
    """

    _data: DaArray
    sampling_rate: float
    label: str
    metadata: dict[str, Any]
    operation_history: list[dict[str, Any]]
    _channel_metadata: list[ChannelMetadata]
    _previous: Optional["BaseFrame[Any]"]

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ):
        self._data = data.rechunk(chunks=-1)  # type: ignore [unused-ignore]
        if self._data.ndim == 1:
            self._data = self._data.reshape((1, -1))
        self.sampling_rate = sampling_rate
        self.label = label or "unnamed_frame"
        self.metadata = metadata or {}
        self.operation_history = operation_history or []
        self._previous = previous

        if channel_metadata:
            # Pydantic handles both ChannelMetadata objects and dicts
            def _to_channel_metadata(
                ch: ChannelMetadata | dict[str, Any], index: int
            ) -> ChannelMetadata:
                if isinstance(ch, ChannelMetadata):
                    return copy.deepcopy(ch)
                elif isinstance(ch, dict):
                    try:
                        return ChannelMetadata(**ch)
                    except ValidationError as e:
                        raise ValueError(
                            f"Invalid channel_metadata at index {index}\n"
                            f"  Got: {ch}\n"
                            f"  Validation error: {e}\n"
                            f"Ensure all dict keys match ChannelMetadata fields "
                            f"(label, unit, ref, extra) and have correct types."
                        ) from e
                else:
                    raise TypeError(
                        f"Invalid type in channel_metadata at index {index}\n"
                        f"  Got: {type(ch).__name__} ({ch!r})\n"
                        f"  Expected: ChannelMetadata or dict\n"
                        f"Use ChannelMetadata objects or dicts with valid fields."
                    )

            self._channel_metadata = [
                _to_channel_metadata(cast(ChannelMetadata | dict[str, Any], ch), i)
                for i, ch in enumerate(channel_metadata)
            ]
        else:
            self._channel_metadata = [
                ChannelMetadata(label=f"ch{i}", unit="", extra={})
                for i in range(self._n_channels)
            ]

        try:
            # Display information for newer dask versions
            logger.debug(f"Dask graph layers: {list(self._data.dask.layers.keys())}")
            logger.debug(
                f"Dask graph dependencies: {len(self._data.dask.dependencies)}"
            )
        except Exception as e:
            logger.debug(f"Dask graph visualization details unavailable: {e}")

    @property
    @abstractmethod
    def _n_channels(self) -> int:
        """Returns the number of channels."""

    @property
    def n_channels(self) -> int:
        """Returns the number of channels."""
        return self._n_channels

    @property
    def channels(self) -> list[ChannelMetadata]:
        """Property to access channel metadata."""
        return self._channel_metadata

    @property
    def previous(self) -> Optional["BaseFrame[Any]"]:
        """
        Returns the previous frame.
        """
        return self._previous

    def get_channel(
        self: S,
        channel_idx: int
        | list[int]
        | tuple[int, ...]
        | npt.NDArray[np.int_]
        | npt.NDArray[np.bool_],
    ) -> S:
        """
        Get channel(s) by index.

        Parameters
        ----------
        channel_idx : int or sequence of int
            Single channel index or sequence of channel indices.
            Supports negative indices (e.g., -1 for the last channel).

        Returns
        -------
        S
            New instance containing the selected channel(s).

        Examples
        --------
        >>> frame.get_channel(0)  # Single channel
        >>> frame.get_channel([0, 2, 3])  # Multiple channels
        >>> frame.get_channel((-1, -2))  # Last two channels
        >>> frame.get_channel(np.array([1, 2]))  # NumPy array of indices
        """
        if isinstance(channel_idx, int):
            # Convert single channel to a list.
            channel_idx_list: list[int] = [channel_idx]
        else:
            channel_idx_list = list(channel_idx)

        new_data = self._data[channel_idx_list]
        new_channel_metadata = [self._channel_metadata[i] for i in channel_idx_list]
        return self._create_new_instance(
            data=new_data,
            operation_history=self.operation_history,
            channel_metadata=new_channel_metadata,
        )

    def __len__(self) -> int:
        """
        Returns the number of channels.
        """
        return len(self._channel_metadata)

    def __iter__(self: S) -> Iterator[S]:
        for idx in range(len(self)):
            yield self[idx]

    def __getitem__(
        self: S,
        key: int
        | str
        | slice
        | list[int]
        | list[str]
        | tuple[
            int
            | str
            | slice
            | list[int]
            | list[str]
            | npt.NDArray[np.int_]
            | npt.NDArray[np.bool_],
            ...,
        ]
        | npt.NDArray[np.int_]
        | npt.NDArray[np.bool_],
    ) -> S:
        """
        Get channel(s) by index, label, or advanced indexing.

        This method supports multiple indexing patterns similar to NumPy and pandas:

        - Single channel by index: `frame[0]`
        - Single channel by label: `frame["ch0"]`
        - Slice of channels: `frame[0:3]`
        - Multiple channels by indices: `frame[[0, 2, 5]]`
        - Multiple channels by labels: `frame[["ch0", "ch2"]]`
        - NumPy integer array: `frame[np.array([0, 2])]`
        - Boolean mask: `frame[mask]` where mask is a boolean array
        - Multidimensional indexing: `frame[0, 100:200]` (channel + time)

        Parameters
        ----------
        key : int, str, slice, list, tuple, or ndarray
            - int: Single channel index (supports negative indexing)
            - str: Single channel label
            - slice: Range of channels
            - list[int]: Multiple channel indices
            - list[str]: Multiple channel labels
            - tuple: Multidimensional indexing (channel_key, time_key, ...)
            - ndarray[int]: NumPy array of channel indices
            - ndarray[bool]: Boolean mask for channel selection

        Returns
        -------
        S
            New instance containing the selected channel(s).

        Raises
        ------
        ValueError
            If the key length is invalid for the shape or if boolean mask
            length doesn't match number of channels.
        IndexError
            If the channel index is out of range.
        TypeError
            If the key type is invalid or list contains mixed types.
        KeyError
            If a channel label is not found.

        Examples
        --------
        >>> # Single channel selection
        >>> frame[0]  # First channel
        >>> frame["acc_x"]  # By label
        >>> frame[-1]  # Last channel
        >>>
        >>> # Multiple channel selection
        >>> frame[[0, 2, 5]]  # Multiple indices
        >>> frame[["acc_x", "acc_z"]]  # Multiple labels
        >>> frame[0:3]  # Slice
        >>>
        >>> # NumPy array indexing
        >>> frame[np.array([0, 2, 4])]  # Integer array
        >>> mask = np.array([True, False, True])
        >>> frame[mask]  # Boolean mask
        >>>
        >>> # Time slicing (multidimensional)
        >>> frame[0, 100:200]  # Channel 0, samples 100-200
        >>> frame[[0, 1], ::2]  # Channels 0-1, every 2nd sample
        """

        # Single index (int)
        if isinstance(key, numbers.Integral):
            # Ensure we pass a plain Python int to satisfy the type checker
            return self.get_channel(int(key))

        # Single label (str)
        if isinstance(key, str):
            index = self.label2index(key)
            return self.get_channel(index)

        # Phase 2: NumPy array support (bool mask and int array)
        if isinstance(key, np.ndarray):
            if key.dtype == bool or key.dtype == np.bool_:
                # Boolean mask
                if len(key) != self.n_channels:
                    raise ValueError(
                        f"Boolean mask length {len(key)} does not match "
                        f"number of channels {self.n_channels}"
                    )
                indices = np.where(key)[0]
                return self.get_channel(indices)
            elif np.issubdtype(key.dtype, np.integer):
                # Integer array
                return self.get_channel(key)
            else:
                raise TypeError(
                    f"NumPy array must be of integer or boolean type, got {key.dtype}"
                )

        # Phase 1: List support (int or str)
        if isinstance(key, list):
            if len(key) == 0:
                raise ValueError("Cannot index with an empty list")

            # Check if all elements are strings
            if all(isinstance(k, str) for k in key):
                # Multiple labels - type narrowing for mypy
                str_list = cast(list[str], key)
                indices_from_labels = [self.label2index(label) for label in str_list]
                return self.get_channel(indices_from_labels)

            # Check if all elements are integers
            elif all(isinstance(k, int | np.integer) for k in key):
                # Multiple indices - convert to list[int] for type safety
                int_list = [int(k) for k in key]
                return self.get_channel(int_list)

            else:
                raise TypeError(
                    f"List must contain all str or all int, got mixed types: "
                    f"{[type(k).__name__ for k in key]}"
                )

        # Tuple: multidimensional indexing
        if isinstance(key, tuple):
            return self._handle_multidim_indexing(key)

        # Slice
        if isinstance(key, slice):
            new_data = self._data[key]
            new_channel_metadata = self._channel_metadata[key]
            if isinstance(new_channel_metadata, ChannelMetadata):
                new_channel_metadata = [new_channel_metadata]
            return self._create_new_instance(
                data=new_data,
                operation_history=self.operation_history,
                channel_metadata=new_channel_metadata,
            )

        raise TypeError(
            f"Invalid key type: {type(key).__name__}. "
            f"Expected int, str, slice, list, tuple, or ndarray."
        )

    def _handle_multidim_indexing(
        self: S,
        key: tuple[
            int
            | str
            | slice
            | list[int]
            | list[str]
            | npt.NDArray[np.int_]
            | npt.NDArray[np.bool_],
            ...,
        ],
    ) -> S:
        """
        Handle multidimensional indexing (channel + time axis).

        Parameters
        ----------
        key : tuple
            Tuple of indices where the first element selects channels
            and subsequent elements select along other dimensions (e.g., time).

        Returns
        -------
        S
            New instance with selected channels and time range.

        Raises
        ------
        ValueError
            If the key length exceeds the data dimensions.
        """
        if len(key) > self._data.ndim:
            raise ValueError(f"Invalid key length: {len(key)} for shape {self.shape}")

        # First element: channel selection
        channel_key = key[0]
        time_keys = key[1:] if len(key) > 1 else ()

        # Select channels first (recursively call __getitem__)
        if isinstance(channel_key, list | np.ndarray):
            selected = self[channel_key]
        elif isinstance(channel_key, int | str | slice):
            selected = self[channel_key]
        else:
            raise TypeError(
                f"Invalid channel key type in tuple: {type(channel_key).__name__}"
            )

        # Apply time indexing if present
        if time_keys:
            new_data = selected._data[(slice(None),) + time_keys]
            return selected._create_new_instance(
                data=new_data,
                operation_history=selected.operation_history,
                channel_metadata=selected._channel_metadata,
            )

        return selected

    def label2index(self, label: str) -> int:
        """
        Get the index from a channel label.

        Parameters
        ----------
        label : str
            Channel label.

        Returns
        -------
        int
            Corresponding index.

        Raises
        ------
        KeyError
            If the channel label is not found.
        """
        for idx, ch in enumerate(self._channel_metadata):
            if ch.label == label:
                return idx
        raise KeyError(f"Channel label '{label}' not found.")

    @property
    def shape(self) -> tuple[int, ...]:
        _shape: tuple[int, ...] = self._data.shape
        if _shape[0] == 1:
            return _shape[1:]
        return _shape

    @property
    def data(self) -> T:
        """
        Returns the computed data.
        Calculation is executed the first time this is accessed.
        """
        data = self.compute()
        if self.n_channels == 1:
            return data.squeeze(axis=0)
        return data

    @property
    def labels(self) -> list[str]:
        """Get a list of all channel labels."""
        return [ch.label for ch in self._channel_metadata]

    def compute(self) -> T:
        """
        Compute and return the data.
        This method materializes lazily computed data into a concrete NumPy array.

        Returns
        -------
        NDArrayReal
            The computed data.

        Raises
        ------
        ValueError
            If the computed result is not a NumPy array.
        """
        logger.debug(
            "COMPUTING DASK ARRAY - This will trigger file reading and all processing"
        )
        result = self._data.compute()

        if not isinstance(result, np.ndarray):
            raise ValueError(f"Computed result is not a np.ndarray: {type(result)}")

        logger.debug(f"Computation complete, result shape: {result.shape}")
        return cast(T, result)

    @abstractmethod
    def plot(
        self, plot_type: str = "default", ax: Axes | None = None, **kwargs: Any
    ) -> Axes | Iterator[Axes]:
        """Plot the data"""
        pass

    def persist(self: S) -> S:
        """Persist the data in memory"""
        persisted_data = self._data.persist()
        return self._create_new_instance(data=persisted_data)

    @abstractmethod
    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Abstract method for derived classes to provide
        additional initialization arguments.
        """
        pass

    def _create_new_instance(self: S, data: DaArray, **kwargs: Any) -> S:
        """
        Create a new channel instance based on an existing channel.
        Keyword arguments can override or extend the original attributes.
        """

        sampling_rate = kwargs.pop("sampling_rate", self.sampling_rate)
        # if not isinstance(sampling_rate, int):
        #     raise TypeError("Sampling rate must be an integer")

        label = kwargs.pop("label", self.label)
        if not isinstance(label, str):
            raise TypeError("Label must be a string")

        metadata = kwargs.pop("metadata", copy.deepcopy(self.metadata))
        if not isinstance(metadata, dict):
            raise TypeError("Metadata must be a dictionary")

        channel_metadata = kwargs.pop(
            "channel_metadata", copy.deepcopy(self._channel_metadata)
        )
        if not isinstance(channel_metadata, list):
            raise TypeError("Channel metadata must be a list")

        # Get additional initialization arguments from derived classes
        additional_kwargs = self._get_additional_init_kwargs()
        kwargs.update(additional_kwargs)

        return type(self)(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            channel_metadata=channel_metadata,
            previous=self,
            **kwargs,
        )

    def __array__(self, dtype: npt.DTypeLike = None) -> NDArrayReal:
        """Implicit conversion to NumPy array"""
        result = self.compute()
        if dtype is not None:
            return result.astype(dtype)
        return result

    def visualize_graph(self, filename: str | None = None) -> IPythonImage | None:
        """
        Visualize the computation graph and save it to a file.

        This method creates a visual representation of the Dask computation graph.
        In Jupyter notebooks, it returns an IPython.display.Image object that
        will be displayed inline. In other environments, it saves the graph to
        a file and returns None.

        Parameters
        ----------
        filename : str, optional
            Output filename for the graph image. If None, a unique filename
            is generated using UUID. The file is saved in the current working
            directory.

        Returns
        -------
        IPython.display.Image or None
            In Jupyter environments: Returns an IPython.display.Image object
            that can be displayed inline.
            In other environments: Returns None after saving the graph to file.
            Returns None if visualization fails.

        Notes
        -----
        This method requires graphviz to be installed on your system:
        - Ubuntu/Debian: `sudo apt-get install graphviz`
        - macOS: `brew install graphviz`
        - Windows: Download from https://graphviz.org/download/

        The graph displays operation names (e.g., 'normalize', 'lowpass_filter')
        making it easier to understand the processing pipeline.

        Examples
        --------
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> processed = signal.normalize().low_pass_filter(cutoff=1000)
        >>> # In Jupyter: displays graph inline
        >>> processed.visualize_graph()
        >>> # Save to specific file
        >>> processed.visualize_graph("my_graph.png")

        See Also
        --------
        debug_info : Print detailed debug information about the frame
        """
        try:
            filename = filename or f"graph_{uuid.uuid4().hex[:8]}.png"
            return self._data.visualize(filename=filename)
        except Exception as e:
            logger.warning(f"Failed to visualize the graph: {e}")
            return None

    @abstractmethod
    def _binary_op(
        self: S,
        other: S | int | float | NDArrayReal | DaArray,
        op: Callable[[DaArray, Any], DaArray],
        symbol: str,
    ) -> S:
        """Basic implementation of binary operations"""
        # Basic logic
        # Actual implementation is left to derived classes
        pass

    def __add__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Addition operator"""
        return self._binary_op(other, lambda x, y: x + y, "+")

    def __sub__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Subtraction operator"""
        return self._binary_op(other, lambda x, y: x - y, "-")

    def __mul__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Multiplication operator"""
        return self._binary_op(other, lambda x, y: x * y, "*")

    def __truediv__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Division operator"""
        return self._binary_op(other, lambda x, y: x / y, "/")

    def __pow__(self: S, other: S | int | float | NDArrayReal) -> S:
        """Power operator"""
        return self._binary_op(other, lambda x, y: x**y, "**")

    def apply_operation(self: S, operation_name: str, **params: Any) -> S:
        """
        Apply a named operation.

        Parameters
        ----------
        operation_name : str
            Name of the operation to apply.
        **params : Any
            Parameters to pass to the operation.

        Returns
        -------
        S
            A new instance with the operation applied.
        """
        # Apply the operation through abstract method
        return self._apply_operation_impl(operation_name, **params)

    @abstractmethod
    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        """Implementation of operation application"""
        pass

    def _relabel_channels(
        self,
        operation_name: str,
        display_name: str | None = None,
    ) -> list[ChannelMetadata]:
        """
        Update channel labels to reflect applied operation.

        This method creates new channel metadata with labels that include
        the operation name, making it easier to track processing history
        and distinguish frames in plots.

        Parameters
        ----------
        operation_name : str
            Name of the operation (e.g., "normalize", "lowpass_filter")
        display_name : str, optional
            Display name for the operation. If None, uses operation_name.
            This allows operations to provide custom, more readable labels.

        Returns
        -------
        list[ChannelMetadata]
            New channel metadata with updated labels.
            Original metadata is deep-copied and only labels are modified.

        Examples
        --------
        >>> # Original label: "ch0"
        >>> # After normalize: "normalize(ch0)"
        >>> # After chained ops: "lowpass_filter(normalize(ch0))"

        Notes
        -----
        Labels are nested for chained operations, allowing full
        traceability of the processing pipeline.
        """
        display = display_name or operation_name
        new_metadata = []
        for ch in self._channel_metadata:
            # All channel metadata are ChannelMetadata objects at this point
            new_ch = ch.model_copy(deep=True)
            new_ch.label = f"{display}({ch.label})"
            new_metadata.append(new_ch)
        return new_metadata

    def debug_info(self) -> None:
        """Output detailed debug information"""
        logger.debug(f"=== {self.__class__.__name__} Debug Info ===")
        logger.debug(f"Label: {self.label}")
        logger.debug(f"Shape: {self.shape}")
        logger.debug(f"Sampling rate: {self.sampling_rate} Hz")
        logger.debug(f"Operation history: {len(self.operation_history)} operations")
        self._debug_info_impl()
        logger.debug("=== End Debug Info ===")

    def print_operation_history(self) -> None:
        """
        Print the operation history to standard output in a readable format.

        This method writes a human-friendly representation of the
        `operation_history` list to stdout. Each operation is printed on its
        own line with an index, the operation name (if available), and the
        parameters used.

        Examples
        --------
        >>> cf.print_operation_history()
        1: normalize {}
        2: low_pass_filter {'cutoff': 1000}
        """
        if not self.operation_history:
            print("Operation history: <empty>")
            return

        print(f"Operation history ({len(self.operation_history)}):")
        for i, record in enumerate(self.operation_history, start=1):
            # record is expected to be a dict with at least a 'operation' key
            op_name = record.get("operation") or record.get("name") or "<unknown>"
            # Copy params for display - exclude the 'operation'/'name' keys
            params = {k: v for k, v in record.items() if k not in ("operation", "name")}
            print(f"{i}: {op_name} {params}")

    def to_numpy(self) -> T:
        """Convert the frame data to a NumPy array.

        This method computes the Dask array and returns it as a concrete NumPy array.
        The returned array has the same shape as the frame's data.

        Returns
        -------
        T
            NumPy array containing the frame data.

        Examples
        --------
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> data = cf.to_numpy()
        >>> print(f"Shape: {data.shape}")  # (n_channels, n_samples)
        """
        return self.data

    def to_dataframe(self) -> "pd.DataFrame":
        """Convert the frame data to a pandas DataFrame.

        This method provides a common implementation for converting frame data
        to pandas DataFrame. Subclasses can override this method for custom behavior.

        Returns
        -------
        pd.DataFrame
            DataFrame with appropriate index and columns.

        Examples
        --------
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> df = cf.to_dataframe()
        >>> print(df.head())
        """
        # Get data as numpy array
        data = self.to_numpy()

        # Get column names from subclass
        columns = self._get_dataframe_columns()

        # Get index from subclass
        index = self._get_dataframe_index()

        # Create DataFrame
        if data.ndim == 1:
            # Single channel case - reshape to 2D
            df = pd.DataFrame(data.reshape(-1, 1), columns=columns, index=index)
        else:
            # Multi-channel case - transpose to (n_samples, n_channels)
            df = pd.DataFrame(data.T, columns=columns, index=index)

        return df

    @abstractmethod
    def _get_dataframe_columns(self) -> list[str]:
        """Get column names for DataFrame.

        This method should be implemented by subclasses to provide
        appropriate column names for the DataFrame.

        Returns
        -------
        list[str]
            List of column names.
        """
        pass

    @abstractmethod
    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get index for DataFrame.

        This method should be implemented by subclasses to provide
        appropriate index for the DataFrame based on the frame type.

        Returns
        -------
        pd.Index
            Index for the DataFrame.
        """
        pass

    def _debug_info_impl(self) -> None:
        """Implement derived class-specific debug information"""
        pass

    def _print_operation_history(self) -> None:
        """Print the operation history information.

        This is a helper method for info() implementations to display
        the number of operations applied to the frame in a consistent format.
        """
        if self.operation_history:
            print(f"  Operations Applied: {len(self.operation_history)}")
        else:
            print("  Operations Applied: None")
Attributes
sampling_rate = sampling_rate instance-attribute
label = label or 'unnamed_frame' instance-attribute
metadata = metadata or {} instance-attribute
operation_history = operation_history or [] instance-attribute
n_channels property

Returns the number of channels.

channels property

Property to access channel metadata.

previous property

Returns the previous frame.

shape property
data property

Returns the computed data. Calculation is executed the first time this is accessed.

labels property

Get a list of all channel labels.

Functions
__init__(data, sampling_rate, label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)
Source code in wandas/core/base_frame.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
):
    self._data = data.rechunk(chunks=-1)  # type: ignore [unused-ignore]
    if self._data.ndim == 1:
        self._data = self._data.reshape((1, -1))
    self.sampling_rate = sampling_rate
    self.label = label or "unnamed_frame"
    self.metadata = metadata or {}
    self.operation_history = operation_history or []
    self._previous = previous

    if channel_metadata:
        # Pydantic handles both ChannelMetadata objects and dicts
        def _to_channel_metadata(
            ch: ChannelMetadata | dict[str, Any], index: int
        ) -> ChannelMetadata:
            if isinstance(ch, ChannelMetadata):
                return copy.deepcopy(ch)
            elif isinstance(ch, dict):
                try:
                    return ChannelMetadata(**ch)
                except ValidationError as e:
                    raise ValueError(
                        f"Invalid channel_metadata at index {index}\n"
                        f"  Got: {ch}\n"
                        f"  Validation error: {e}\n"
                        f"Ensure all dict keys match ChannelMetadata fields "
                        f"(label, unit, ref, extra) and have correct types."
                    ) from e
            else:
                raise TypeError(
                    f"Invalid type in channel_metadata at index {index}\n"
                    f"  Got: {type(ch).__name__} ({ch!r})\n"
                    f"  Expected: ChannelMetadata or dict\n"
                    f"Use ChannelMetadata objects or dicts with valid fields."
                )

        self._channel_metadata = [
            _to_channel_metadata(cast(ChannelMetadata | dict[str, Any], ch), i)
            for i, ch in enumerate(channel_metadata)
        ]
    else:
        self._channel_metadata = [
            ChannelMetadata(label=f"ch{i}", unit="", extra={})
            for i in range(self._n_channels)
        ]

    try:
        # Display information for newer dask versions
        logger.debug(f"Dask graph layers: {list(self._data.dask.layers.keys())}")
        logger.debug(
            f"Dask graph dependencies: {len(self._data.dask.dependencies)}"
        )
    except Exception as e:
        logger.debug(f"Dask graph visualization details unavailable: {e}")
get_channel(channel_idx)

Get channel(s) by index.

Parameters

channel_idx : int or sequence of int Single channel index or sequence of channel indices. Supports negative indices (e.g., -1 for the last channel).

Returns

S New instance containing the selected channel(s).

Examples

frame.get_channel(0) # Single channel frame.get_channel([0, 2, 3]) # Multiple channels frame.get_channel((-1, -2)) # Last two channels frame.get_channel(np.array([1, 2])) # NumPy array of indices

Source code in wandas/core/base_frame.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def get_channel(
    self: S,
    channel_idx: int
    | list[int]
    | tuple[int, ...]
    | npt.NDArray[np.int_]
    | npt.NDArray[np.bool_],
) -> S:
    """
    Get channel(s) by index.

    Parameters
    ----------
    channel_idx : int or sequence of int
        Single channel index or sequence of channel indices.
        Supports negative indices (e.g., -1 for the last channel).

    Returns
    -------
    S
        New instance containing the selected channel(s).

    Examples
    --------
    >>> frame.get_channel(0)  # Single channel
    >>> frame.get_channel([0, 2, 3])  # Multiple channels
    >>> frame.get_channel((-1, -2))  # Last two channels
    >>> frame.get_channel(np.array([1, 2]))  # NumPy array of indices
    """
    if isinstance(channel_idx, int):
        # Convert single channel to a list.
        channel_idx_list: list[int] = [channel_idx]
    else:
        channel_idx_list = list(channel_idx)

    new_data = self._data[channel_idx_list]
    new_channel_metadata = [self._channel_metadata[i] for i in channel_idx_list]
    return self._create_new_instance(
        data=new_data,
        operation_history=self.operation_history,
        channel_metadata=new_channel_metadata,
    )
__len__()

Returns the number of channels.

Source code in wandas/core/base_frame.py
202
203
204
205
206
def __len__(self) -> int:
    """
    Returns the number of channels.
    """
    return len(self._channel_metadata)
__iter__()
Source code in wandas/core/base_frame.py
208
209
210
def __iter__(self: S) -> Iterator[S]:
    for idx in range(len(self)):
        yield self[idx]
__getitem__(key)

Get channel(s) by index, label, or advanced indexing.

This method supports multiple indexing patterns similar to NumPy and pandas:

  • Single channel by index: frame[0]
  • Single channel by label: frame["ch0"]
  • Slice of channels: frame[0:3]
  • Multiple channels by indices: frame[[0, 2, 5]]
  • Multiple channels by labels: frame[["ch0", "ch2"]]
  • NumPy integer array: frame[np.array([0, 2])]
  • Boolean mask: frame[mask] where mask is a boolean array
  • Multidimensional indexing: frame[0, 100:200] (channel + time)
Parameters

key : int, str, slice, list, tuple, or ndarray - int: Single channel index (supports negative indexing) - str: Single channel label - slice: Range of channels - list[int]: Multiple channel indices - list[str]: Multiple channel labels - tuple: Multidimensional indexing (channel_key, time_key, ...) - ndarray[int]: NumPy array of channel indices - ndarray[bool]: Boolean mask for channel selection

Returns

S New instance containing the selected channel(s).

Raises

ValueError If the key length is invalid for the shape or if boolean mask length doesn't match number of channels. IndexError If the channel index is out of range. TypeError If the key type is invalid or list contains mixed types. KeyError If a channel label is not found.

Examples
Single channel selection

frame[0] # First channel frame["acc_x"] # By label frame[-1] # Last channel

Multiple channel selection

frame[[0, 2, 5]] # Multiple indices frame[["acc_x", "acc_z"]] # Multiple labels frame[0:3] # Slice

NumPy array indexing

frame[np.array([0, 2, 4])] # Integer array mask = np.array([True, False, True]) frame[mask] # Boolean mask

Time slicing (multidimensional)

frame[0, 100:200] # Channel 0, samples 100-200 frame[[0, 1], ::2] # Channels 0-1, every 2nd sample

Source code in wandas/core/base_frame.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def __getitem__(
    self: S,
    key: int
    | str
    | slice
    | list[int]
    | list[str]
    | tuple[
        int
        | str
        | slice
        | list[int]
        | list[str]
        | npt.NDArray[np.int_]
        | npt.NDArray[np.bool_],
        ...,
    ]
    | npt.NDArray[np.int_]
    | npt.NDArray[np.bool_],
) -> S:
    """
    Get channel(s) by index, label, or advanced indexing.

    This method supports multiple indexing patterns similar to NumPy and pandas:

    - Single channel by index: `frame[0]`
    - Single channel by label: `frame["ch0"]`
    - Slice of channels: `frame[0:3]`
    - Multiple channels by indices: `frame[[0, 2, 5]]`
    - Multiple channels by labels: `frame[["ch0", "ch2"]]`
    - NumPy integer array: `frame[np.array([0, 2])]`
    - Boolean mask: `frame[mask]` where mask is a boolean array
    - Multidimensional indexing: `frame[0, 100:200]` (channel + time)

    Parameters
    ----------
    key : int, str, slice, list, tuple, or ndarray
        - int: Single channel index (supports negative indexing)
        - str: Single channel label
        - slice: Range of channels
        - list[int]: Multiple channel indices
        - list[str]: Multiple channel labels
        - tuple: Multidimensional indexing (channel_key, time_key, ...)
        - ndarray[int]: NumPy array of channel indices
        - ndarray[bool]: Boolean mask for channel selection

    Returns
    -------
    S
        New instance containing the selected channel(s).

    Raises
    ------
    ValueError
        If the key length is invalid for the shape or if boolean mask
        length doesn't match number of channels.
    IndexError
        If the channel index is out of range.
    TypeError
        If the key type is invalid or list contains mixed types.
    KeyError
        If a channel label is not found.

    Examples
    --------
    >>> # Single channel selection
    >>> frame[0]  # First channel
    >>> frame["acc_x"]  # By label
    >>> frame[-1]  # Last channel
    >>>
    >>> # Multiple channel selection
    >>> frame[[0, 2, 5]]  # Multiple indices
    >>> frame[["acc_x", "acc_z"]]  # Multiple labels
    >>> frame[0:3]  # Slice
    >>>
    >>> # NumPy array indexing
    >>> frame[np.array([0, 2, 4])]  # Integer array
    >>> mask = np.array([True, False, True])
    >>> frame[mask]  # Boolean mask
    >>>
    >>> # Time slicing (multidimensional)
    >>> frame[0, 100:200]  # Channel 0, samples 100-200
    >>> frame[[0, 1], ::2]  # Channels 0-1, every 2nd sample
    """

    # Single index (int)
    if isinstance(key, numbers.Integral):
        # Ensure we pass a plain Python int to satisfy the type checker
        return self.get_channel(int(key))

    # Single label (str)
    if isinstance(key, str):
        index = self.label2index(key)
        return self.get_channel(index)

    # Phase 2: NumPy array support (bool mask and int array)
    if isinstance(key, np.ndarray):
        if key.dtype == bool or key.dtype == np.bool_:
            # Boolean mask
            if len(key) != self.n_channels:
                raise ValueError(
                    f"Boolean mask length {len(key)} does not match "
                    f"number of channels {self.n_channels}"
                )
            indices = np.where(key)[0]
            return self.get_channel(indices)
        elif np.issubdtype(key.dtype, np.integer):
            # Integer array
            return self.get_channel(key)
        else:
            raise TypeError(
                f"NumPy array must be of integer or boolean type, got {key.dtype}"
            )

    # Phase 1: List support (int or str)
    if isinstance(key, list):
        if len(key) == 0:
            raise ValueError("Cannot index with an empty list")

        # Check if all elements are strings
        if all(isinstance(k, str) for k in key):
            # Multiple labels - type narrowing for mypy
            str_list = cast(list[str], key)
            indices_from_labels = [self.label2index(label) for label in str_list]
            return self.get_channel(indices_from_labels)

        # Check if all elements are integers
        elif all(isinstance(k, int | np.integer) for k in key):
            # Multiple indices - convert to list[int] for type safety
            int_list = [int(k) for k in key]
            return self.get_channel(int_list)

        else:
            raise TypeError(
                f"List must contain all str or all int, got mixed types: "
                f"{[type(k).__name__ for k in key]}"
            )

    # Tuple: multidimensional indexing
    if isinstance(key, tuple):
        return self._handle_multidim_indexing(key)

    # Slice
    if isinstance(key, slice):
        new_data = self._data[key]
        new_channel_metadata = self._channel_metadata[key]
        if isinstance(new_channel_metadata, ChannelMetadata):
            new_channel_metadata = [new_channel_metadata]
        return self._create_new_instance(
            data=new_data,
            operation_history=self.operation_history,
            channel_metadata=new_channel_metadata,
        )

    raise TypeError(
        f"Invalid key type: {type(key).__name__}. "
        f"Expected int, str, slice, list, tuple, or ndarray."
    )
label2index(label)

Get the index from a channel label.

Parameters

label : str Channel label.

Returns

int Corresponding index.

Raises

KeyError If the channel label is not found.

Source code in wandas/core/base_frame.py
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def label2index(self, label: str) -> int:
    """
    Get the index from a channel label.

    Parameters
    ----------
    label : str
        Channel label.

    Returns
    -------
    int
        Corresponding index.

    Raises
    ------
    KeyError
        If the channel label is not found.
    """
    for idx, ch in enumerate(self._channel_metadata):
        if ch.label == label:
            return idx
    raise KeyError(f"Channel label '{label}' not found.")
compute()

Compute and return the data. This method materializes lazily computed data into a concrete NumPy array.

Returns

NDArrayReal The computed data.

Raises

ValueError If the computed result is not a NumPy array.

Source code in wandas/core/base_frame.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
def compute(self) -> T:
    """
    Compute and return the data.
    This method materializes lazily computed data into a concrete NumPy array.

    Returns
    -------
    NDArrayReal
        The computed data.

    Raises
    ------
    ValueError
        If the computed result is not a NumPy array.
    """
    logger.debug(
        "COMPUTING DASK ARRAY - This will trigger file reading and all processing"
    )
    result = self._data.compute()

    if not isinstance(result, np.ndarray):
        raise ValueError(f"Computed result is not a np.ndarray: {type(result)}")

    logger.debug(f"Computation complete, result shape: {result.shape}")
    return cast(T, result)
plot(plot_type='default', ax=None, **kwargs) abstractmethod

Plot the data

Source code in wandas/core/base_frame.py
504
505
506
507
508
509
@abstractmethod
def plot(
    self, plot_type: str = "default", ax: Axes | None = None, **kwargs: Any
) -> Axes | Iterator[Axes]:
    """Plot the data"""
    pass
persist()

Persist the data in memory

Source code in wandas/core/base_frame.py
511
512
513
514
def persist(self: S) -> S:
    """Persist the data in memory"""
    persisted_data = self._data.persist()
    return self._create_new_instance(data=persisted_data)
__array__(dtype=None)

Implicit conversion to NumPy array

Source code in wandas/core/base_frame.py
562
563
564
565
566
567
def __array__(self, dtype: npt.DTypeLike = None) -> NDArrayReal:
    """Implicit conversion to NumPy array"""
    result = self.compute()
    if dtype is not None:
        return result.astype(dtype)
    return result
visualize_graph(filename=None)

Visualize the computation graph and save it to a file.

This method creates a visual representation of the Dask computation graph. In Jupyter notebooks, it returns an IPython.display.Image object that will be displayed inline. In other environments, it saves the graph to a file and returns None.

Parameters

filename : str, optional Output filename for the graph image. If None, a unique filename is generated using UUID. The file is saved in the current working directory.

Returns

IPython.display.Image or None In Jupyter environments: Returns an IPython.display.Image object that can be displayed inline. In other environments: Returns None after saving the graph to file. Returns None if visualization fails.

Notes

This method requires graphviz to be installed on your system: - Ubuntu/Debian: sudo apt-get install graphviz - macOS: brew install graphviz - Windows: Download from https://graphviz.org/download/

The graph displays operation names (e.g., 'normalize', 'lowpass_filter') making it easier to understand the processing pipeline.

Examples

import wandas as wd signal = wd.read_wav("audio.wav") processed = signal.normalize().low_pass_filter(cutoff=1000)

In Jupyter: displays graph inline

processed.visualize_graph()

Save to specific file

processed.visualize_graph("my_graph.png")

See Also

debug_info : Print detailed debug information about the frame

Source code in wandas/core/base_frame.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
def visualize_graph(self, filename: str | None = None) -> IPythonImage | None:
    """
    Visualize the computation graph and save it to a file.

    This method creates a visual representation of the Dask computation graph.
    In Jupyter notebooks, it returns an IPython.display.Image object that
    will be displayed inline. In other environments, it saves the graph to
    a file and returns None.

    Parameters
    ----------
    filename : str, optional
        Output filename for the graph image. If None, a unique filename
        is generated using UUID. The file is saved in the current working
        directory.

    Returns
    -------
    IPython.display.Image or None
        In Jupyter environments: Returns an IPython.display.Image object
        that can be displayed inline.
        In other environments: Returns None after saving the graph to file.
        Returns None if visualization fails.

    Notes
    -----
    This method requires graphviz to be installed on your system:
    - Ubuntu/Debian: `sudo apt-get install graphviz`
    - macOS: `brew install graphviz`
    - Windows: Download from https://graphviz.org/download/

    The graph displays operation names (e.g., 'normalize', 'lowpass_filter')
    making it easier to understand the processing pipeline.

    Examples
    --------
    >>> import wandas as wd
    >>> signal = wd.read_wav("audio.wav")
    >>> processed = signal.normalize().low_pass_filter(cutoff=1000)
    >>> # In Jupyter: displays graph inline
    >>> processed.visualize_graph()
    >>> # Save to specific file
    >>> processed.visualize_graph("my_graph.png")

    See Also
    --------
    debug_info : Print detailed debug information about the frame
    """
    try:
        filename = filename or f"graph_{uuid.uuid4().hex[:8]}.png"
        return self._data.visualize(filename=filename)
    except Exception as e:
        logger.warning(f"Failed to visualize the graph: {e}")
        return None
__add__(other)

Addition operator

Source code in wandas/core/base_frame.py
636
637
638
def __add__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Addition operator"""
    return self._binary_op(other, lambda x, y: x + y, "+")
__sub__(other)

Subtraction operator

Source code in wandas/core/base_frame.py
640
641
642
def __sub__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Subtraction operator"""
    return self._binary_op(other, lambda x, y: x - y, "-")
__mul__(other)

Multiplication operator

Source code in wandas/core/base_frame.py
644
645
646
def __mul__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Multiplication operator"""
    return self._binary_op(other, lambda x, y: x * y, "*")
__truediv__(other)

Division operator

Source code in wandas/core/base_frame.py
648
649
650
def __truediv__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Division operator"""
    return self._binary_op(other, lambda x, y: x / y, "/")
__pow__(other)

Power operator

Source code in wandas/core/base_frame.py
652
653
654
def __pow__(self: S, other: S | int | float | NDArrayReal) -> S:
    """Power operator"""
    return self._binary_op(other, lambda x, y: x**y, "**")
apply_operation(operation_name, **params)

Apply a named operation.

Parameters

operation_name : str Name of the operation to apply. **params : Any Parameters to pass to the operation.

Returns

S A new instance with the operation applied.

Source code in wandas/core/base_frame.py
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
def apply_operation(self: S, operation_name: str, **params: Any) -> S:
    """
    Apply a named operation.

    Parameters
    ----------
    operation_name : str
        Name of the operation to apply.
    **params : Any
        Parameters to pass to the operation.

    Returns
    -------
    S
        A new instance with the operation applied.
    """
    # Apply the operation through abstract method
    return self._apply_operation_impl(operation_name, **params)
debug_info()

Output detailed debug information

Source code in wandas/core/base_frame.py
726
727
728
729
730
731
732
733
734
def debug_info(self) -> None:
    """Output detailed debug information"""
    logger.debug(f"=== {self.__class__.__name__} Debug Info ===")
    logger.debug(f"Label: {self.label}")
    logger.debug(f"Shape: {self.shape}")
    logger.debug(f"Sampling rate: {self.sampling_rate} Hz")
    logger.debug(f"Operation history: {len(self.operation_history)} operations")
    self._debug_info_impl()
    logger.debug("=== End Debug Info ===")
print_operation_history()

Print the operation history to standard output in a readable format.

This method writes a human-friendly representation of the operation_history list to stdout. Each operation is printed on its own line with an index, the operation name (if available), and the parameters used.

Examples

cf.print_operation_history() 1: normalize {} 2: low_pass_filter {'cutoff': 1000}

Source code in wandas/core/base_frame.py
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
def print_operation_history(self) -> None:
    """
    Print the operation history to standard output in a readable format.

    This method writes a human-friendly representation of the
    `operation_history` list to stdout. Each operation is printed on its
    own line with an index, the operation name (if available), and the
    parameters used.

    Examples
    --------
    >>> cf.print_operation_history()
    1: normalize {}
    2: low_pass_filter {'cutoff': 1000}
    """
    if not self.operation_history:
        print("Operation history: <empty>")
        return

    print(f"Operation history ({len(self.operation_history)}):")
    for i, record in enumerate(self.operation_history, start=1):
        # record is expected to be a dict with at least a 'operation' key
        op_name = record.get("operation") or record.get("name") or "<unknown>"
        # Copy params for display - exclude the 'operation'/'name' keys
        params = {k: v for k, v in record.items() if k not in ("operation", "name")}
        print(f"{i}: {op_name} {params}")
to_numpy()

Convert the frame data to a NumPy array.

This method computes the Dask array and returns it as a concrete NumPy array. The returned array has the same shape as the frame's data.

Returns

T NumPy array containing the frame data.

Examples

cf = ChannelFrame.read_wav("audio.wav") data = cf.to_numpy() print(f"Shape: {data.shape}") # (n_channels, n_samples)

Source code in wandas/core/base_frame.py
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
def to_numpy(self) -> T:
    """Convert the frame data to a NumPy array.

    This method computes the Dask array and returns it as a concrete NumPy array.
    The returned array has the same shape as the frame's data.

    Returns
    -------
    T
        NumPy array containing the frame data.

    Examples
    --------
    >>> cf = ChannelFrame.read_wav("audio.wav")
    >>> data = cf.to_numpy()
    >>> print(f"Shape: {data.shape}")  # (n_channels, n_samples)
    """
    return self.data
to_dataframe()

Convert the frame data to a pandas DataFrame.

This method provides a common implementation for converting frame data to pandas DataFrame. Subclasses can override this method for custom behavior.

Returns

pd.DataFrame DataFrame with appropriate index and columns.

Examples

cf = ChannelFrame.read_wav("audio.wav") df = cf.to_dataframe() print(df.head())

Source code in wandas/core/base_frame.py
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
def to_dataframe(self) -> "pd.DataFrame":
    """Convert the frame data to a pandas DataFrame.

    This method provides a common implementation for converting frame data
    to pandas DataFrame. Subclasses can override this method for custom behavior.

    Returns
    -------
    pd.DataFrame
        DataFrame with appropriate index and columns.

    Examples
    --------
    >>> cf = ChannelFrame.read_wav("audio.wav")
    >>> df = cf.to_dataframe()
    >>> print(df.head())
    """
    # Get data as numpy array
    data = self.to_numpy()

    # Get column names from subclass
    columns = self._get_dataframe_columns()

    # Get index from subclass
    index = self._get_dataframe_index()

    # Create DataFrame
    if data.ndim == 1:
        # Single channel case - reshape to 2D
        df = pd.DataFrame(data.reshape(-1, 1), columns=columns, index=index)
    else:
        # Multi-channel case - transpose to (n_samples, n_channels)
        df = pd.DataFrame(data.T, columns=columns, index=index)

    return df

metadata

Classes
ChannelMetadata

Bases: BaseModel

Data class for storing channel metadata

Source code in wandas/core/metadata.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class ChannelMetadata(BaseModel):
    """
    Data class for storing channel metadata
    """

    label: str = ""
    unit: str = ""
    ref: float = 1.0
    # Additional metadata for extensibility
    extra: dict[str, Any] = Field(default_factory=dict)

    def __init__(self, **data: Any):
        super().__init__(**data)
        # unitが指定されていてrefがデフォルト値ならunit_to_refで自動設定
        if self.unit and ("ref" not in data or data.get("ref", 1.0) == 1.0):
            self.ref = unit_to_ref(self.unit)

    def __setattr__(self, name: str, value: Any) -> None:
        """Override setattr to update ref when unit is changed directly"""
        super().__setattr__(name, value)
        # Only proceed if unit is being set to a non-empty value
        if name == "unit" and value and isinstance(value, str):
            super().__setattr__("ref", unit_to_ref(value))

    @property
    def label_value(self) -> str:
        """Get the label value"""
        return self.label

    @property
    def unit_value(self) -> str:
        """Get the unit value"""
        return self.unit

    @property
    def ref_value(self) -> float:
        """Get the ref value"""
        return self.ref

    @property
    def extra_data(self) -> dict[str, Any]:
        """Get the extra metadata dictionary"""
        return self.extra

    def __getitem__(self, key: str) -> Any:
        """Provide dictionary-like behavior"""
        if key == "label":
            return self.label
        elif key == "unit":
            return self.unit
        elif key == "ref":
            return self.ref
        else:
            return self.extra.get(key)

    def __setitem__(self, key: str, value: Any) -> None:
        """Provide dictionary-like behavior"""
        if key == "label":
            self.label = value
        elif key == "unit":
            self.unit = value
            self.ref = unit_to_ref(value)
        elif key == "ref":
            self.ref = value
        else:
            self.extra[key] = value

    def to_json(self) -> str:
        """Convert to JSON format"""
        json_data: str = self.model_dump_json(indent=4)
        return json_data

    @classmethod
    def from_json(cls, json_data: str) -> "ChannelMetadata":
        """Convert from JSON format"""
        root_model: ChannelMetadata = ChannelMetadata.model_validate_json(json_data)

        return root_model
Attributes
label = '' class-attribute instance-attribute
unit = '' class-attribute instance-attribute
ref = 1.0 class-attribute instance-attribute
extra = Field(default_factory=dict) class-attribute instance-attribute
label_value property

Get the label value

unit_value property

Get the unit value

ref_value property

Get the ref value

extra_data property

Get the extra metadata dictionary

Functions
__init__(**data)
Source code in wandas/core/metadata.py
19
20
21
22
23
def __init__(self, **data: Any):
    super().__init__(**data)
    # unitが指定されていてrefがデフォルト値ならunit_to_refで自動設定
    if self.unit and ("ref" not in data or data.get("ref", 1.0) == 1.0):
        self.ref = unit_to_ref(self.unit)
__setattr__(name, value)

Override setattr to update ref when unit is changed directly

Source code in wandas/core/metadata.py
25
26
27
28
29
30
def __setattr__(self, name: str, value: Any) -> None:
    """Override setattr to update ref when unit is changed directly"""
    super().__setattr__(name, value)
    # Only proceed if unit is being set to a non-empty value
    if name == "unit" and value and isinstance(value, str):
        super().__setattr__("ref", unit_to_ref(value))
__getitem__(key)

Provide dictionary-like behavior

Source code in wandas/core/metadata.py
52
53
54
55
56
57
58
59
60
61
def __getitem__(self, key: str) -> Any:
    """Provide dictionary-like behavior"""
    if key == "label":
        return self.label
    elif key == "unit":
        return self.unit
    elif key == "ref":
        return self.ref
    else:
        return self.extra.get(key)
__setitem__(key, value)

Provide dictionary-like behavior

Source code in wandas/core/metadata.py
63
64
65
66
67
68
69
70
71
72
73
def __setitem__(self, key: str, value: Any) -> None:
    """Provide dictionary-like behavior"""
    if key == "label":
        self.label = value
    elif key == "unit":
        self.unit = value
        self.ref = unit_to_ref(value)
    elif key == "ref":
        self.ref = value
    else:
        self.extra[key] = value
to_json()

Convert to JSON format

Source code in wandas/core/metadata.py
75
76
77
78
def to_json(self) -> str:
    """Convert to JSON format"""
    json_data: str = self.model_dump_json(indent=4)
    return json_data
from_json(json_data) classmethod

Convert from JSON format

Source code in wandas/core/metadata.py
80
81
82
83
84
85
@classmethod
def from_json(cls, json_data: str) -> "ChannelMetadata":
    """Convert from JSON format"""
    root_model: ChannelMetadata = ChannelMetadata.model_validate_json(json_data)

    return root_model
Functions

フレームモジュール

フレームモジュールは異なるタイプのデータフレームを定義します。

wandas.frames

Frame classes for wandas.

Attributes

__all__ = ['ChannelFrame', 'RoughnessFrame'] module-attribute

Classes

ChannelFrame

Bases: BaseFrame[NDArrayReal], ChannelProcessingMixin, ChannelTransformMixin

Channel-based data frame for handling audio signals and time series data.

This frame represents channel-based data such as audio signals and time series data, with each channel containing data samples in the time domain.

Source code in wandas/frames/channel.py
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
class ChannelFrame(
    BaseFrame[NDArrayReal], ChannelProcessingMixin, ChannelTransformMixin
):
    """Channel-based data frame for handling audio signals and time series data.

    This frame represents channel-based data such as audio signals and time series data,
    with each channel containing data samples in the time domain.
    """

    def __init__(
        self,
        data: DaskArray,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> None:
        """Initialize a ChannelFrame.

        Args:
            data: Dask array containing channel data.
            Shape should be (n_channels, n_samples).
            sampling_rate: The sampling rate of the data in Hz.
                Must be a positive value.
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            operation_history: History of operations applied to the frame.
            channel_metadata: Metadata for each channel.
            previous: Reference to the previous frame in the processing chain.

        Raises:
            ValueError: If data has more than 2 dimensions, or if
                sampling_rate is not positive.
        """
        # Validate sampling rate
        validate_sampling_rate(sampling_rate)

        # Validate and reshape data
        if data.ndim == 1:
            data = da.reshape(data, (1, -1))
        elif data.ndim > 2:
            raise ValueError(
                f"Invalid data shape for ChannelFrame\n"
                f"  Got: {data.shape} ({data.ndim}D)\n"
                f"  Expected: 1D (samples,) or 2D (channels, samples)\n"
                f"If you have a 1D array, it will be automatically reshaped to\n"
                f"  (1, n_samples).\n"
                f"For higher-dimensional data, reshape it before creating\n"
                f"  ChannelFrame:\n"
                f"  Example: data.reshape(n_channels, -1)"
            )
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def _n_channels(self) -> int:
        """Returns the number of channels."""
        return int(self._data.shape[-2])

    @property
    def time(self) -> NDArrayReal:
        """Get time array for the signal.

        The time array represents the start time of each sample, calculated as
        sample_index / sampling_rate. This provides a uniform, evenly-spaced
        time axis that is consistent across all frame types in wandas.

        For frames resulting from windowed analysis operations (e.g., FFT,
        loudness, roughness), each time point corresponds to the start of
        the analysis window, not the center. This differs from some libraries
        (e.g., MoSQITo) which use window center times, but does not affect
        the calculated values themselves.

        Returns:
            Array of time points in seconds, starting from 0.0.

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> time = signal.time
            >>> print(f"Duration: {time[-1]:.3f}s")
            >>> print(f"Time step: {time[1] - time[0]:.6f}s")
        """
        return np.arange(self.n_samples) / self.sampling_rate

    @property
    def n_samples(self) -> int:
        """Returns the number of samples."""
        n: int = self._data.shape[-1]
        return n

    @property
    def duration(self) -> float:
        """Returns the duration in seconds."""
        return self.n_samples / self.sampling_rate

    @property
    def rms(self) -> NDArrayReal:
        """Calculate RMS (Root Mean Square) value for each channel.

        Returns:
            Array of RMS values, one per channel.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> rms_values = cf.rms
            >>> print(f"RMS values: {rms_values}")
            >>> # Select channels with RMS > threshold
            >>> active_channels = cf[cf.rms > 0.5]
        """
        # Convert to a concrete NumPy ndarray to satisfy numpy.mean typing
        # and to ensure dask arrays are materialized for this operation.
        rms_values = da.sqrt((self._data**2).mean(axis=1))
        return np.array(rms_values.compute())

    def info(self) -> None:
        """Display comprehensive information about the ChannelFrame.

        This method prints a summary of the frame's properties including:
        - Number of channels
        - Sampling rate
        - Duration
        - Number of samples
        - Channel labels

        This is a convenience method to view all key properties at once,
        similar to pandas DataFrame.info().

        Examples
        --------
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> cf.info()
        Channels: 2
        Sampling rate: 44100 Hz
        Duration: 1.0 s
        Samples: 44100
        Channel labels: ['ch0', 'ch1']
        """
        print("ChannelFrame Information:")
        print(f"  Channels: {self.n_channels}")
        print(f"  Sampling rate: {self.sampling_rate} Hz")
        print(f"  Duration: {self.duration:.1f} s")
        print(f"  Samples: {self.n_samples}")
        print(f"  Channel labels: {self.labels}")
        self._print_operation_history()

    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
        from ..processing import create_operation

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)

        # Apply processing to data
        processed_data = operation.process(self._data)

        # Update metadata
        operation_metadata = {"operation": operation_name, "params": params}
        new_history = self.operation_history.copy()
        new_history.append(operation_metadata)
        new_metadata = {**self.metadata}
        new_metadata[operation_name] = params

        # Get metadata updates from operation
        metadata_updates = operation.get_metadata_updates()

        # Update channel labels to reflect the operation
        display_name = operation.get_display_name()
        new_channel_metadata = self._relabel_channels(operation_name, display_name)

        logger.debug(
            f"Created new ChannelFrame with operation {operation_name} added to graph"
        )

        # Apply metadata updates (including sampling_rate if specified)
        creation_params: dict[str, Any] = {
            "data": processed_data,
            "metadata": new_metadata,
            "operation_history": new_history,
            "channel_metadata": new_channel_metadata,
        }
        creation_params.update(metadata_updates)

        return self._create_new_instance(**creation_params)

    def _binary_op(
        self,
        other: "ChannelFrame | int | float | NDArrayReal | DaskArray",
        op: Callable[["DaskArray", Any], "DaskArray"],
        symbol: str,
    ) -> "ChannelFrame":
        """
        Common implementation for binary operations
        - utilizing dask's lazy evaluation.

        Args:
            other: Right operand for the operation.
            op: Function to execute the operation (e.g., lambda a, b: a + b).
            symbol: Symbolic representation of the operation (e.g., '+').

        Returns:
            A new channel containing the operation result (lazy execution).
        """
        from .channel import ChannelFrame

        logger.debug(f"Setting up {symbol} operation (lazy)")

        # Handle potentially None metadata and operation_history
        metadata = {}
        if self.metadata is not None:
            metadata = self.metadata.copy()

        operation_history = []
        if self.operation_history is not None:
            operation_history = self.operation_history.copy()

        # Check if other is a ChannelFrame - improved type checking
        if isinstance(other, ChannelFrame):
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    "Sampling rates do not match. Cannot perform operation."
                )

            # Perform operation directly on dask array (maintaining lazy execution)
            result_data = op(self._data, other._data)

            # Merge channel metadata
            merged_channel_metadata = []
            for self_ch, other_ch in zip(
                self._channel_metadata, other._channel_metadata
            ):
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch['label']} {symbol} {other_ch['label']})"
                merged_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other.label})

            return ChannelFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                label=f"({self.label} {symbol} {other.label})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=merged_channel_metadata,
                previous=self,
            )

        # Perform operation with scalar, NumPy array, or other types
        else:
            # Apply operation directly on dask array (maintaining lazy execution)
            result_data = op(self._data, other)

            # Operand display string
            if isinstance(other, int | float):
                other_str = str(other)
            elif isinstance(other, np.ndarray):
                other_str = f"ndarray{other.shape}"
            elif hasattr(other, "shape"):  # Check for dask.array.Array
                other_str = f"dask.array{other.shape}"
            else:
                other_str = str(type(other).__name__)

            # Update channel metadata
            updated_channel_metadata: list[ChannelMetadata] = []
            for self_ch in self._channel_metadata:
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch.label} {symbol} {other_str})"
                updated_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other_str})

            return ChannelFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                label=f"({self.label} {symbol} {other_str})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=updated_channel_metadata,
                previous=self,
            )

    def add(
        self,
        other: "ChannelFrame | int | float | NDArrayReal",
        snr: float | None = None,
    ) -> "ChannelFrame":
        """Add another signal or value to the current signal.

        If SNR is specified, performs addition with consideration for
        signal-to-noise ratio.

        Args:
            other: Signal or value to add.
            snr: Signal-to-noise ratio (dB). If specified, adjusts the scale of the
                other signal based on this SNR.
                self is treated as the signal, and other as the noise.

        Returns:
            A new channel frame containing the addition result (lazy execution).
        """
        logger.debug(f"Setting up add operation with SNR={snr} (lazy)")

        if isinstance(other, ChannelFrame):
            # Check if sampling rates match
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    "Sampling rates do not match. Cannot perform operation."
                )

        elif isinstance(other, np.ndarray):
            other = ChannelFrame.from_numpy(
                other, self.sampling_rate, label="array_data"
            )
        elif isinstance(other, int | float):
            return self + other
        else:
            raise TypeError(
                "Addition target with SNR must be a ChannelFrame or "
                f"NumPy array: {type(other)}"
            )

        # If SNR is specified, adjust the length of the other signal
        if other.duration != self.duration:
            other = other.fix_length(length=self.n_samples)

        if snr is None:
            return self + other
        return self.apply_operation("add_with_snr", other=other._data, snr=snr)

    def plot(
        self,
        plot_type: str = "waveform",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        xlabel: str | None = None,
        ylabel: str | None = None,
        alpha: float = 1.0,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Plot the frame data.

        Args:
            plot_type: Type of plot. Default is "waveform".
            ax: Optional matplotlib axes for plotting.
            title: Title for the plot. If None, uses the frame label.
            overlay: Whether to overlay all channels on a single plot (True)
                or create separate subplots for each channel (False).
            xlabel: Label for the x-axis. If None, uses default based on plot type.
            ylabel: Label for the y-axis. If None, uses default based on plot type.
            alpha: Transparency level for the plot lines (0.0 to 1.0).
            xlim: Limits for the x-axis as (min, max) tuple.
            ylim: Limits for the y-axis as (min, max) tuple.
            **kwargs: Additional matplotlib Line2D parameters
                (e.g., color, linewidth, linestyle).
                These are passed to the underlying matplotlib plot functions.

        Returns:
            Single Axes object or iterator of Axes objects.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Basic plot
            >>> cf.plot()
            >>> # Overlay all channels
            >>> cf.plot(overlay=True, alpha=0.7)
            >>> # Custom styling
            >>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
        """
        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        from ..visualization.plotting import create_operation

        plot_strategy = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "overlay": overlay,
            **kwargs,
        }
        if xlabel is not None:
            plot_kwargs["xlabel"] = xlabel
        if ylabel is not None:
            plot_kwargs["ylabel"] = ylabel
        if alpha != 1.0:
            plot_kwargs["alpha"] = alpha
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def rms_plot(
        self,
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = True,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Generate an RMS plot.

        Args:
            ax: Optional matplotlib axes for plotting.
            title: Title for the plot.
            overlay: Whether to overlay the plot on the existing axis.
            Aw: Apply A-weighting.
            **kwargs: Additional arguments passed to the plot() method.
                Accepts the same arguments as plot() including xlabel, ylabel,
                alpha, xlim, ylim, and matplotlib Line2D parameters.

        Returns:
            Single Axes object or iterator of Axes objects.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Basic RMS plot
            >>> cf.rms_plot()
            >>> # With A-weighting
            >>> cf.rms_plot(Aw=True)
            >>> # Custom styling
            >>> cf.rms_plot(ylabel="RMS [V]", alpha=0.8, color="blue")
        """
        kwargs = kwargs or {}
        ylabel = kwargs.pop("ylabel", "RMS")
        rms_ch: ChannelFrame = self.rms_trend(Aw=Aw, dB=True)
        return rms_ch.plot(ax=ax, ylabel=ylabel, title=title, overlay=overlay, **kwargs)

    def describe(
        self,
        normalize: bool = True,
        is_close: bool = True,
        *,
        fmin: float = 0,
        fmax: float | None = None,
        cmap: str = "jet",
        vmin: float | None = None,
        vmax: float | None = None,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        waveform: dict[str, Any] | None = None,
        spectral: dict[str, Any] | None = None,
        **kwargs: Any,
    ) -> None:
        """Display visual and audio representation of the frame.

        This method creates a comprehensive visualization with three plots:
        1. Time-domain waveform (top)
        2. Spectrogram (bottom-left)
        3. Frequency spectrum via Welch method (bottom-right)

        Args:
            normalize: Whether to normalize the audio data for playback.
                Default: True
            is_close: Whether to close the figure after displaying.
                Default: True
            fmin: Minimum frequency to display in the spectrogram (Hz).
                Default: 0
            fmax: Maximum frequency to display in the spectrogram (Hz).
                Default: Nyquist frequency (sampling_rate / 2)
            cmap: Colormap for the spectrogram.
                Default: 'jet'
            vmin: Minimum value for spectrogram color scale (dB).
                Auto-calculated if None.
            vmax: Maximum value for spectrogram color scale (dB).
                Auto-calculated if None.
            xlim: Time axis limits (seconds) for all time-based plots.
                Format: (start_time, end_time)
            ylim: Frequency axis limits (Hz) for frequency-based plots.
                Format: (min_freq, max_freq)
            Aw: Apply A-weighting to the frequency analysis.
                Default: False
            waveform: Additional configuration dict for waveform subplot.
                Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
            spectral: Additional configuration dict for spectral subplot.
                Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
            **kwargs: Deprecated parameters for backward compatibility only.
                - axis_config: Old configuration format (use waveform/spectral instead)
                - cbar_config: Old colorbar configuration (use vmin/vmax instead)

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Basic usage
            >>> cf.describe()
            >>>
            >>> # Custom frequency range
            >>> cf.describe(fmin=100, fmax=5000)
            >>>
            >>> # Custom color scale
            >>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
            >>>
            >>> # A-weighted analysis
            >>> cf.describe(Aw=True)
            >>>
            >>> # Custom time range
            >>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
            >>>
            >>> # Custom waveform subplot settings
            >>> cf.describe(waveform={"ylabel": "Custom Label"})
        """
        # Prepare kwargs with explicit parameters
        plot_kwargs: dict[str, Any] = {
            "fmin": fmin,
            "fmax": fmax,
            "cmap": cmap,
            "vmin": vmin,
            "vmax": vmax,
            "xlim": xlim,
            "ylim": ylim,
            "Aw": Aw,
            "waveform": waveform or {},
            "spectral": spectral or {},
        }
        # Merge with additional kwargs
        plot_kwargs.update(kwargs)

        if "axis_config" in plot_kwargs:
            logger.warning(
                "axis_config is retained for backward compatibility but will "
                "be deprecated in the future."
            )
            axis_config = plot_kwargs["axis_config"]
            if "time_plot" in axis_config:
                plot_kwargs["waveform"] = axis_config["time_plot"]
            if "freq_plot" in axis_config:
                if "xlim" in axis_config["freq_plot"]:
                    vlim = axis_config["freq_plot"]["xlim"]
                    plot_kwargs["vmin"] = vlim[0]
                    plot_kwargs["vmax"] = vlim[1]
                if "ylim" in axis_config["freq_plot"]:
                    ylim_config = axis_config["freq_plot"]["ylim"]
                    plot_kwargs["ylim"] = ylim_config

        if "cbar_config" in plot_kwargs:
            logger.warning(
                "cbar_config is retained for backward compatibility but will "
                "be deprecated in the future."
            )
            cbar_config = plot_kwargs["cbar_config"]
            if "vmin" in cbar_config:
                plot_kwargs["vmin"] = cbar_config["vmin"]
            if "vmax" in cbar_config:
                plot_kwargs["vmax"] = cbar_config["vmax"]

        for ch in self:
            ax: Axes
            _ax = ch.plot("describe", title=f"{ch.label} {ch.labels[0]}", **plot_kwargs)
            if isinstance(_ax, Iterator):
                ax = next(iter(_ax))
            elif isinstance(_ax, Axes):
                ax = _ax
            else:
                raise TypeError(
                    f"Unexpected type for plot result: {type(_ax)}. Expected Axes or Iterator[Axes]."  # noqa: E501
                )
            # display関数とAudioクラスを使用
            display(ax.figure)
            if is_close:
                plt.close(getattr(ax, "figure", None))
            display(Audio(ch.data, rate=ch.sampling_rate, normalize=normalize))

    @classmethod
    def from_numpy(
        cls,
        data: NDArrayReal,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        ch_labels: list[str] | None = None,
        ch_units: list[str] | str | None = None,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from a NumPy array.

        Args:
            data: NumPy array containing channel data.
            sampling_rate: The sampling rate in Hz.
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            ch_labels: Labels for each channel.
            ch_units: Units for each channel.

        Returns:
            A new ChannelFrame containing the NumPy data.
        """
        if data.ndim == 1:
            data = data.reshape(1, -1)
        elif data.ndim > 2:
            raise ValueError(
                f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}"
            )

        # Convert NumPy array to dask array
        dask_data = da_from_array(data)
        cf = cls(
            data=dask_data,
            sampling_rate=sampling_rate,
            label=label or "numpy_data",
        )
        if metadata is not None:
            cf.metadata = metadata
        if ch_labels is not None:
            if len(ch_labels) != cf.n_channels:
                raise ValueError(
                    "Number of channel labels does not match the number of channels"
                )
            for i in range(len(ch_labels)):
                cf._channel_metadata[i].label = ch_labels[i]
        if ch_units is not None:
            if isinstance(ch_units, str):
                ch_units = [ch_units] * cf.n_channels

            if len(ch_units) != cf.n_channels:
                raise ValueError(
                    "Number of channel units does not match the number of channels"
                )
            for i in range(len(ch_units)):
                cf._channel_metadata[i].unit = ch_units[i]

        return cf

    @classmethod
    def from_ndarray(
        cls,
        array: NDArrayReal,
        sampling_rate: float,
        labels: list[str] | None = None,
        unit: list[str] | str | None = None,
        frame_label: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from a NumPy array.

        This method is deprecated. Use from_numpy instead.

        Args:
            array: Signal data. Each row corresponds to a channel.
            sampling_rate: Sampling rate (Hz).
            labels: Labels for each channel.
            unit: Unit of the signal.
            frame_label: Label for the frame.
            metadata: Optional metadata dictionary.

        Returns:
            A new ChannelFrame containing the data.
        """
        # Redirect to from_numpy for compatibility
        # However, from_ndarray is deprecated
        logger.warning("from_ndarray is deprecated. Use from_numpy instead.")
        return cls.from_numpy(
            data=array,
            sampling_rate=sampling_rate,
            label=frame_label,
            metadata=metadata,
            ch_labels=labels,
            ch_units=unit,
        )

    @classmethod
    def from_file(
        cls,
        path: str | Path,
        channel: int | list[int] | None = None,
        start: float | None = None,
        end: float | None = None,
        chunk_size: int | None = None,
        ch_labels: list[str] | None = None,
        # CSV-specific parameters
        time_column: int | str = 0,
        delimiter: str = ",",
        header: int | None = 0,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from an audio file.

        Args:
            path: Path to the audio file.
            channel: Channel(s) to load.
            start: Start time in seconds.
            end: End time in seconds.
            chunk_size: Chunk size for processing.
                Specifies the splitting size for lazy processing.
            ch_labels: Labels for each channel.
            time_column: For CSV files, index or name of the time column.
                Default is 0 (first column).
            delimiter: For CSV files, delimiter character. Default is ",".
            header: For CSV files, row number to use as header.
                Default is 0 (first row). Set to None if no header.

        Returns:
            A new ChannelFrame containing the loaded audio data.

        Raises:
            ValueError: If channel specification is invalid.
            TypeError: If channel parameter type is invalid.
            FileNotFoundError: If the file doesn't exist at the specified path.
                Error message includes absolute path, current directory, and
                troubleshooting suggestions.

        Examples:
            >>> # Load WAV file
            >>> cf = ChannelFrame.from_file("audio.wav")
            >>> # Load specific channels
            >>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
            >>> # Load CSV file
            >>> cf = ChannelFrame.from_file(
            ...     "data.csv", time_column=0, delimiter=",", header=0
            ... )
        """
        from .channel import ChannelFrame

        path = Path(path)
        if not path.exists():
            raise FileNotFoundError(
                f"Audio file not found\n"
                f"  Path: {path.absolute()}\n"
                f"  Current directory: {Path.cwd()}\n"
                f"Please check:\n"
                f"  - File path is correct\n"
                f"  - File exists at the specified location\n"
                f"  - You have read permissions for the file"
            )

        # Get file reader
        reader = get_file_reader(path)

        # Build kwargs for reader
        reader_kwargs: dict[str, Any] = {}
        if path.suffix.lower() == ".csv":
            reader_kwargs["time_column"] = time_column
            reader_kwargs["delimiter"] = delimiter
            if header is not None:
                reader_kwargs["header"] = header

        # Get file info
        info = reader.get_file_info(path, **reader_kwargs)
        sr = info["samplerate"]
        n_channels = info["channels"]
        n_frames = info["frames"]
        ch_labels = ch_labels or info.get("ch_labels", None)

        logger.debug(f"File info: sr={sr}, channels={n_channels}, frames={n_frames}")

        # Channel selection processing
        all_channels = list(range(n_channels))

        if channel is None:
            channels_to_load = all_channels
            logger.debug(f"Will load all channels: {channels_to_load}")
        elif isinstance(channel, int):
            if channel < 0 or channel >= n_channels:
                raise ValueError(
                    f"Channel specification is out of range: {channel} (valid range: 0-{n_channels - 1})"  # noqa: E501
                )
            channels_to_load = [channel]
            logger.debug(f"Will load single channel: {channel}")
        elif isinstance(channel, list | tuple):
            for ch in channel:
                if ch < 0 or ch >= n_channels:
                    raise ValueError(
                        f"Channel specification is out of range: {ch} (valid range: 0-{n_channels - 1})"  # noqa: E501
                    )
            channels_to_load = list(channel)
            logger.debug(f"Will load specific channels: {channels_to_load}")
        else:
            raise TypeError("channel must be int, list, or None")

        # Index calculation
        start_idx = 0 if start is None else max(0, int(start * sr))
        end_idx = n_frames if end is None else min(n_frames, int(end * sr))
        frames_to_read = end_idx - start_idx

        logger.debug(
            f"Setting up lazy load from file={path}, frames={frames_to_read}, "
            f"start_idx={start_idx}, end_idx={end_idx}"
        )

        # Settings for lazy loading
        expected_shape = (len(channels_to_load), frames_to_read)

        # Define the loading function using the file reader
        def _load_audio() -> NDArrayReal:
            logger.debug(">>> EXECUTING DELAYED LOAD <<<")
            # Use the reader to get audio data with parameters
            out = reader.get_data(
                path, channels_to_load, start_idx, frames_to_read, **reader_kwargs
            )
            if not isinstance(out, np.ndarray):
                raise ValueError("Unexpected data type after reading file")
            return out

        logger.debug(
            f"Creating delayed dask task with expected shape: {expected_shape}"
        )

        # Create delayed operation
        delayed_data = dask_delayed(_load_audio)()
        logger.debug("Wrapping delayed function in dask array")

        # Create dask array from delayed computation
        dask_array = da_from_delayed(
            delayed_data, shape=expected_shape, dtype=np.float32
        )

        if chunk_size is not None:
            if chunk_size <= 0:
                raise ValueError("Chunk size must be a positive integer")
            logger.debug(f"Setting chunk size: {chunk_size} for sample axis")
            dask_array = dask_array.rechunk({0: -1, 1: chunk_size})

        logger.debug(
            "ChannelFrame setup complete - actual file reading will occur on compute()"  # noqa: E501
        )

        cf = ChannelFrame(
            data=dask_array,
            sampling_rate=sr,
            label=path.stem,
            metadata={
                "filename": str(path),
            },
        )
        if ch_labels is not None:
            if len(ch_labels) != len(cf):
                raise ValueError(
                    "Number of channel labels does not match the number of specified channels"  # noqa: E501
                )
            for i in range(len(ch_labels)):
                cf._channel_metadata[i].label = ch_labels[i]
        return cf

    @classmethod
    def read_wav(cls, filename: str, labels: list[str] | None = None) -> "ChannelFrame":
        """Utility method to read a WAV file.

        Args:
            filename: Path to the WAV file.
            labels: Labels to set for each channel.

        Returns:
            A new ChannelFrame containing the data (lazy loading).
        """
        from .channel import ChannelFrame

        cf = ChannelFrame.from_file(filename, ch_labels=labels)
        return cf

    @classmethod
    def read_csv(
        cls,
        filename: str,
        time_column: int | str = 0,
        labels: list[str] | None = None,
        delimiter: str = ",",
        header: int | None = 0,
    ) -> "ChannelFrame":
        """Utility method to read a CSV file.

        Args:
            filename: Path to the CSV file.
            time_column: Index or name of the time column.
            labels: Labels to set for each channel.
            delimiter: Delimiter character.
            header: Row number to use as header.

        Returns:
            A new ChannelFrame containing the data (lazy loading).

        Examples:
            >>> # Read CSV with default settings
            >>> cf = ChannelFrame.read_csv("data.csv")
            >>> # Read CSV with custom delimiter
            >>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
            >>> # Read CSV without header
            >>> cf = ChannelFrame.read_csv("data.csv", header=None)
        """
        from .channel import ChannelFrame

        cf = ChannelFrame.from_file(
            filename,
            ch_labels=labels,
            time_column=time_column,
            delimiter=delimiter,
            header=header,
        )
        return cf

    def to_wav(self, path: str | Path, format: str | None = None) -> None:
        """Save the audio data to a WAV file.

        Args:
            path: Path to save the file.
            format: File format. If None, determined from file extension.
        """
        from wandas.io.wav_io import write_wav

        write_wav(str(path), self, format=format)

    def save(
        self,
        path: str | Path,
        *,
        format: str = "hdf5",
        compress: str | None = "gzip",
        overwrite: bool = False,
        dtype: str | np.dtype[Any] | None = None,
    ) -> None:
        """Save the ChannelFrame to a WDF (Wandas Data File) format.

        This saves the complete frame including all channel data and metadata
        in a format that can be loaded back with full fidelity.

        Args:
            path: Path to save the file. '.wdf' extension will be added if not present.
            format: Format to use (currently only 'hdf5' is supported)
            compress: Compression method ('gzip' by default, None for no compression)
            overwrite: Whether to overwrite existing file
            dtype: Optional data type conversion before saving (e.g. 'float32')

        Raises:
            FileExistsError: If the file exists and overwrite=False.
            NotImplementedError: For unsupported formats.

        Example:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> cf.save("audio_analysis.wdf")
        """
        from ..io.wdf_io import save as wdf_save

        wdf_save(
            self,
            path,
            format=format,
            compress=compress,
            overwrite=overwrite,
            dtype=dtype,
        )

    @classmethod
    def load(cls, path: str | Path, *, format: str = "hdf5") -> "ChannelFrame":
        """Load a ChannelFrame from a WDF (Wandas Data File) file.

        This loads data saved with the save() method, preserving all channel data,
        metadata, labels, and units.

        Args:
            path: Path to the WDF file
            format: Format of the file (currently only 'hdf5' is supported)

        Returns:
            A new ChannelFrame with all data and metadata loaded

        Raises:
            FileNotFoundError: If the file doesn't exist
            NotImplementedError: For unsupported formats

        Example:
            >>> cf = ChannelFrame.load("audio_analysis.wdf")
        """
        from ..io.wdf_io import load as wdf_load

        return wdf_load(path, format=format)

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """Provide additional initialization arguments required for ChannelFrame."""
        return {}

    def add_channel(
        self,
        data: "np.ndarray[Any, Any] | DaskArray | ChannelFrame",
        label: str | None = None,
        align: str = "strict",
        suffix_on_dup: str | None = None,
        inplace: bool = False,
    ) -> "ChannelFrame":
        """Add a new channel to the frame.

        Args:
            data: Data to add as a new channel. Can be:
                - numpy array (1D or 2D)
                - dask array (1D or 2D)
                - ChannelFrame (channels will be added)
            label: Label for the new channel. If None, generates a default label.
                Ignored when data is a ChannelFrame (uses its channel labels).
            align: How to handle length mismatches:
                - "strict": Raise error if lengths don't match
                - "pad": Pad shorter data with zeros
                - "truncate": Truncate longer data to match
            suffix_on_dup: Suffix to add to duplicate labels. If None, raises error.
            inplace: If True, modifies the frame in place.
                Otherwise returns a new frame.

        Returns:
            Modified ChannelFrame (self if inplace=True, new frame otherwise).

        Raises:
            ValueError: If data length doesn't match and align="strict",
                or if label is duplicate and suffix_on_dup is None.
            TypeError: If data type is not supported.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Add a numpy array as a new channel
            >>> new_data = np.sin(2 * np.pi * 440 * cf.time)
            >>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
            >>> # Add another ChannelFrame's channels
            >>> cf2 = ChannelFrame.read_wav("audio2.wav")
            >>> cf_combined = cf.add_channel(cf2)
        """
        # ndarray/dask/同型Frame対応
        if isinstance(data, ChannelFrame):
            if self.sampling_rate != data.sampling_rate:
                raise ValueError("sampling_rate不一致")
            if data.n_samples != self.n_samples:
                if align == "pad":
                    pad_len = self.n_samples - data.n_samples
                    arr = data._data
                    if pad_len > 0:
                        arr = concatenate(
                            [
                                arr,
                                from_array(
                                    np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                                ),
                            ],
                            axis=1,
                        )
                    else:
                        arr = arr[:, : self.n_samples]
                elif align == "truncate":
                    arr = data._data[:, : self.n_samples]
                    if arr.shape[1] < self.n_samples:
                        pad_len = self.n_samples - arr.shape[1]
                        arr = concatenate(
                            [
                                arr,
                                from_array(
                                    np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                                ),
                            ],
                            axis=1,
                        )
                else:
                    raise ValueError("データ長不一致: align指定を確認")
            else:
                arr = data._data
            labels = [ch.label for ch in self._channel_metadata]
            new_labels = []
            new_metadata_list = []
            for chmeta in data._channel_metadata:
                new_label = chmeta.label
                if new_label in labels or new_label in new_labels:
                    if suffix_on_dup:
                        new_label += suffix_on_dup
                    else:
                        raise ValueError(f"label重複: {new_label}")
                new_labels.append(new_label)
                # Copy the entire channel_metadata and update only the label
                new_ch_meta = chmeta.model_copy(deep=True)
                new_ch_meta.label = new_label
                new_metadata_list.append(new_ch_meta)
            new_data = concatenate([self._data, arr], axis=0)

            new_chmeta = self._channel_metadata + new_metadata_list
            if inplace:
                self._data = new_data
                self._channel_metadata = new_chmeta
                return self
            else:
                return ChannelFrame(
                    data=new_data,
                    sampling_rate=self.sampling_rate,
                    label=self.label,
                    metadata=self.metadata,
                    operation_history=self.operation_history,
                    channel_metadata=new_chmeta,
                    previous=self,
                )
        if isinstance(data, np.ndarray):
            arr = from_array(data.reshape(1, -1))
        elif isinstance(data, DaskArray):
            arr = data[None, ...] if data.ndim == 1 else data
            if arr.shape[0] != 1:
                arr = arr.reshape((1, -1))
        else:
            raise TypeError("add_channel: ndarray/dask/同型Frameのみ対応")
        if arr.shape[1] != self.n_samples:
            if align == "pad":
                pad_len = self.n_samples - arr.shape[1]
                if pad_len > 0:
                    arr = concatenate(
                        [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                        axis=1,
                    )
                else:
                    arr = arr[:, : self.n_samples]
            elif align == "truncate":
                arr = arr[:, : self.n_samples]
                if arr.shape[1] < self.n_samples:
                    pad_len = self.n_samples - arr.shape[1]
                    arr = concatenate(
                        [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                        axis=1,
                    )
            else:
                raise ValueError("データ長不一致: align指定を確認")
        labels = [ch.label for ch in self._channel_metadata]
        new_label = label or f"ch{len(labels)}"
        if new_label in labels:
            if suffix_on_dup:
                new_label += suffix_on_dup
            else:
                raise ValueError("label重複")
        new_data = concatenate([self._data, arr], axis=0)
        from ..core.metadata import ChannelMetadata

        new_chmeta = self._channel_metadata + [ChannelMetadata(label=new_label)]
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )

    def remove_channel(self, key: int | str, inplace: bool = False) -> "ChannelFrame":
        if isinstance(key, int):
            if not (0 <= key < self.n_channels):
                raise IndexError(f"index {key} out of range")
            idx = key
        else:
            labels = [ch.label for ch in self._channel_metadata]
            if key not in labels:
                raise KeyError(f"label {key} not found")
            idx = labels.index(key)
        new_data = self._data[[i for i in range(self.n_channels) if i != idx], :]
        new_chmeta = [ch for i, ch in enumerate(self._channel_metadata) if i != idx]
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get time index for DataFrame."""
        return pd.Index(self.time, name="time")
Attributes
time property

Get time array for the signal.

The time array represents the start time of each sample, calculated as sample_index / sampling_rate. This provides a uniform, evenly-spaced time axis that is consistent across all frame types in wandas.

For frames resulting from windowed analysis operations (e.g., FFT, loudness, roughness), each time point corresponds to the start of the analysis window, not the center. This differs from some libraries (e.g., MoSQITo) which use window center times, but does not affect the calculated values themselves.

Returns:

Type Description
NDArrayReal

Array of time points in seconds, starting from 0.0.

Examples:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> time = signal.time
>>> print(f"Duration: {time[-1]:.3f}s")
>>> print(f"Time step: {time[1] - time[0]:.6f}s")
n_samples property

Returns the number of samples.

duration property

Returns the duration in seconds.

rms property

Calculate RMS (Root Mean Square) value for each channel.

Returns:

Type Description
NDArrayReal

Array of RMS values, one per channel.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> rms_values = cf.rms
>>> print(f"RMS values: {rms_values}")
>>> # Select channels with RMS > threshold
>>> active_channels = cf[cf.rms > 0.5]
Functions
__init__(data, sampling_rate, label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)

Initialize a ChannelFrame.

Parameters:

Name Type Description Default
data Array

Dask array containing channel data.

required
sampling_rate float

The sampling rate of the data in Hz. Must be a positive value.

required
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
operation_history list[dict[str, Any]] | None

History of operations applied to the frame.

None
channel_metadata list[ChannelMetadata] | list[dict[str, Any]] | None

Metadata for each channel.

None
previous Optional[BaseFrame[Any]]

Reference to the previous frame in the processing chain.

None

Raises:

Type Description
ValueError

If data has more than 2 dimensions, or if sampling_rate is not positive.

Source code in wandas/frames/channel.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def __init__(
    self,
    data: DaskArray,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> None:
    """Initialize a ChannelFrame.

    Args:
        data: Dask array containing channel data.
        Shape should be (n_channels, n_samples).
        sampling_rate: The sampling rate of the data in Hz.
            Must be a positive value.
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        operation_history: History of operations applied to the frame.
        channel_metadata: Metadata for each channel.
        previous: Reference to the previous frame in the processing chain.

    Raises:
        ValueError: If data has more than 2 dimensions, or if
            sampling_rate is not positive.
    """
    # Validate sampling rate
    validate_sampling_rate(sampling_rate)

    # Validate and reshape data
    if data.ndim == 1:
        data = da.reshape(data, (1, -1))
    elif data.ndim > 2:
        raise ValueError(
            f"Invalid data shape for ChannelFrame\n"
            f"  Got: {data.shape} ({data.ndim}D)\n"
            f"  Expected: 1D (samples,) or 2D (channels, samples)\n"
            f"If you have a 1D array, it will be automatically reshaped to\n"
            f"  (1, n_samples).\n"
            f"For higher-dimensional data, reshape it before creating\n"
            f"  ChannelFrame:\n"
            f"  Example: data.reshape(n_channels, -1)"
        )
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
info()

Display comprehensive information about the ChannelFrame.

This method prints a summary of the frame's properties including: - Number of channels - Sampling rate - Duration - Number of samples - Channel labels

This is a convenience method to view all key properties at once, similar to pandas DataFrame.info().

Examples

cf = ChannelFrame.read_wav("audio.wav") cf.info() Channels: 2 Sampling rate: 44100 Hz Duration: 1.0 s Samples: 44100 Channel labels: ['ch0', 'ch1']

Source code in wandas/frames/channel.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def info(self) -> None:
    """Display comprehensive information about the ChannelFrame.

    This method prints a summary of the frame's properties including:
    - Number of channels
    - Sampling rate
    - Duration
    - Number of samples
    - Channel labels

    This is a convenience method to view all key properties at once,
    similar to pandas DataFrame.info().

    Examples
    --------
    >>> cf = ChannelFrame.read_wav("audio.wav")
    >>> cf.info()
    Channels: 2
    Sampling rate: 44100 Hz
    Duration: 1.0 s
    Samples: 44100
    Channel labels: ['ch0', 'ch1']
    """
    print("ChannelFrame Information:")
    print(f"  Channels: {self.n_channels}")
    print(f"  Sampling rate: {self.sampling_rate} Hz")
    print(f"  Duration: {self.duration:.1f} s")
    print(f"  Samples: {self.n_samples}")
    print(f"  Channel labels: {self.labels}")
    self._print_operation_history()
add(other, snr=None)

Add another signal or value to the current signal.

If SNR is specified, performs addition with consideration for signal-to-noise ratio.

Parameters:

Name Type Description Default
other ChannelFrame | int | float | NDArrayReal

Signal or value to add.

required
snr float | None

Signal-to-noise ratio (dB). If specified, adjusts the scale of the other signal based on this SNR. self is treated as the signal, and other as the noise.

None

Returns:

Type Description
ChannelFrame

A new channel frame containing the addition result (lazy execution).

Source code in wandas/frames/channel.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
def add(
    self,
    other: "ChannelFrame | int | float | NDArrayReal",
    snr: float | None = None,
) -> "ChannelFrame":
    """Add another signal or value to the current signal.

    If SNR is specified, performs addition with consideration for
    signal-to-noise ratio.

    Args:
        other: Signal or value to add.
        snr: Signal-to-noise ratio (dB). If specified, adjusts the scale of the
            other signal based on this SNR.
            self is treated as the signal, and other as the noise.

    Returns:
        A new channel frame containing the addition result (lazy execution).
    """
    logger.debug(f"Setting up add operation with SNR={snr} (lazy)")

    if isinstance(other, ChannelFrame):
        # Check if sampling rates match
        if self.sampling_rate != other.sampling_rate:
            raise ValueError(
                "Sampling rates do not match. Cannot perform operation."
            )

    elif isinstance(other, np.ndarray):
        other = ChannelFrame.from_numpy(
            other, self.sampling_rate, label="array_data"
        )
    elif isinstance(other, int | float):
        return self + other
    else:
        raise TypeError(
            "Addition target with SNR must be a ChannelFrame or "
            f"NumPy array: {type(other)}"
        )

    # If SNR is specified, adjust the length of the other signal
    if other.duration != self.duration:
        other = other.fix_length(length=self.n_samples)

    if snr is None:
        return self + other
    return self.apply_operation("add_with_snr", other=other._data, snr=snr)
plot(plot_type='waveform', ax=None, title=None, overlay=False, xlabel=None, ylabel=None, alpha=1.0, xlim=None, ylim=None, **kwargs)

Plot the frame data.

Parameters:

Name Type Description Default
plot_type str

Type of plot. Default is "waveform".

'waveform'
ax Optional[Axes]

Optional matplotlib axes for plotting.

None
title str | None

Title for the plot. If None, uses the frame label.

None
overlay bool

Whether to overlay all channels on a single plot (True) or create separate subplots for each channel (False).

False
xlabel str | None

Label for the x-axis. If None, uses default based on plot type.

None
ylabel str | None

Label for the y-axis. If None, uses default based on plot type.

None
alpha float

Transparency level for the plot lines (0.0 to 1.0).

1.0
xlim tuple[float, float] | None

Limits for the x-axis as (min, max) tuple.

None
ylim tuple[float, float] | None

Limits for the y-axis as (min, max) tuple.

None
**kwargs Any

Additional matplotlib Line2D parameters (e.g., color, linewidth, linestyle). These are passed to the underlying matplotlib plot functions.

{}

Returns:

Type Description
Axes | Iterator[Axes]

Single Axes object or iterator of Axes objects.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Basic plot
>>> cf.plot()
>>> # Overlay all channels
>>> cf.plot(overlay=True, alpha=0.7)
>>> # Custom styling
>>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
Source code in wandas/frames/channel.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
def plot(
    self,
    plot_type: str = "waveform",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    xlabel: str | None = None,
    ylabel: str | None = None,
    alpha: float = 1.0,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Plot the frame data.

    Args:
        plot_type: Type of plot. Default is "waveform".
        ax: Optional matplotlib axes for plotting.
        title: Title for the plot. If None, uses the frame label.
        overlay: Whether to overlay all channels on a single plot (True)
            or create separate subplots for each channel (False).
        xlabel: Label for the x-axis. If None, uses default based on plot type.
        ylabel: Label for the y-axis. If None, uses default based on plot type.
        alpha: Transparency level for the plot lines (0.0 to 1.0).
        xlim: Limits for the x-axis as (min, max) tuple.
        ylim: Limits for the y-axis as (min, max) tuple.
        **kwargs: Additional matplotlib Line2D parameters
            (e.g., color, linewidth, linestyle).
            These are passed to the underlying matplotlib plot functions.

    Returns:
        Single Axes object or iterator of Axes objects.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Basic plot
        >>> cf.plot()
        >>> # Overlay all channels
        >>> cf.plot(overlay=True, alpha=0.7)
        >>> # Custom styling
        >>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
    """
    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    from ..visualization.plotting import create_operation

    plot_strategy = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "overlay": overlay,
        **kwargs,
    }
    if xlabel is not None:
        plot_kwargs["xlabel"] = xlabel
    if ylabel is not None:
        plot_kwargs["ylabel"] = ylabel
    if alpha != 1.0:
        plot_kwargs["alpha"] = alpha
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax
rms_plot(ax=None, title=None, overlay=True, Aw=False, **kwargs)

Generate an RMS plot.

Parameters:

Name Type Description Default
ax Optional[Axes]

Optional matplotlib axes for plotting.

None
title str | None

Title for the plot.

None
overlay bool

Whether to overlay the plot on the existing axis.

True
Aw bool

Apply A-weighting.

False
**kwargs Any

Additional arguments passed to the plot() method. Accepts the same arguments as plot() including xlabel, ylabel, alpha, xlim, ylim, and matplotlib Line2D parameters.

{}

Returns:

Type Description
Axes | Iterator[Axes]

Single Axes object or iterator of Axes objects.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Basic RMS plot
>>> cf.rms_plot()
>>> # With A-weighting
>>> cf.rms_plot(Aw=True)
>>> # Custom styling
>>> cf.rms_plot(ylabel="RMS [V]", alpha=0.8, color="blue")
Source code in wandas/frames/channel.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
def rms_plot(
    self,
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = True,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Generate an RMS plot.

    Args:
        ax: Optional matplotlib axes for plotting.
        title: Title for the plot.
        overlay: Whether to overlay the plot on the existing axis.
        Aw: Apply A-weighting.
        **kwargs: Additional arguments passed to the plot() method.
            Accepts the same arguments as plot() including xlabel, ylabel,
            alpha, xlim, ylim, and matplotlib Line2D parameters.

    Returns:
        Single Axes object or iterator of Axes objects.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Basic RMS plot
        >>> cf.rms_plot()
        >>> # With A-weighting
        >>> cf.rms_plot(Aw=True)
        >>> # Custom styling
        >>> cf.rms_plot(ylabel="RMS [V]", alpha=0.8, color="blue")
    """
    kwargs = kwargs or {}
    ylabel = kwargs.pop("ylabel", "RMS")
    rms_ch: ChannelFrame = self.rms_trend(Aw=Aw, dB=True)
    return rms_ch.plot(ax=ax, ylabel=ylabel, title=title, overlay=overlay, **kwargs)
describe(normalize=True, is_close=True, *, fmin=0, fmax=None, cmap='jet', vmin=None, vmax=None, xlim=None, ylim=None, Aw=False, waveform=None, spectral=None, **kwargs)

Display visual and audio representation of the frame.

This method creates a comprehensive visualization with three plots: 1. Time-domain waveform (top) 2. Spectrogram (bottom-left) 3. Frequency spectrum via Welch method (bottom-right)

Parameters:

Name Type Description Default
normalize bool

Whether to normalize the audio data for playback. Default: True

True
is_close bool

Whether to close the figure after displaying. Default: True

True
fmin float

Minimum frequency to display in the spectrogram (Hz). Default: 0

0
fmax float | None

Maximum frequency to display in the spectrogram (Hz). Default: Nyquist frequency (sampling_rate / 2)

None
cmap str

Colormap for the spectrogram. Default: 'jet'

'jet'
vmin float | None

Minimum value for spectrogram color scale (dB). Auto-calculated if None.

None
vmax float | None

Maximum value for spectrogram color scale (dB). Auto-calculated if None.

None
xlim tuple[float, float] | None

Time axis limits (seconds) for all time-based plots. Format: (start_time, end_time)

None
ylim tuple[float, float] | None

Frequency axis limits (Hz) for frequency-based plots. Format: (min_freq, max_freq)

None
Aw bool

Apply A-weighting to the frequency analysis. Default: False

False
waveform dict[str, Any] | None

Additional configuration dict for waveform subplot. Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.

None
spectral dict[str, Any] | None

Additional configuration dict for spectral subplot. Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.

None
**kwargs Any

Deprecated parameters for backward compatibility only. - axis_config: Old configuration format (use waveform/spectral instead) - cbar_config: Old colorbar configuration (use vmin/vmax instead)

{}

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Basic usage
>>> cf.describe()
>>>
>>> # Custom frequency range
>>> cf.describe(fmin=100, fmax=5000)
>>>
>>> # Custom color scale
>>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
>>>
>>> # A-weighted analysis
>>> cf.describe(Aw=True)
>>>
>>> # Custom time range
>>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
>>>
>>> # Custom waveform subplot settings
>>> cf.describe(waveform={"ylabel": "Custom Label"})
Source code in wandas/frames/channel.py
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
def describe(
    self,
    normalize: bool = True,
    is_close: bool = True,
    *,
    fmin: float = 0,
    fmax: float | None = None,
    cmap: str = "jet",
    vmin: float | None = None,
    vmax: float | None = None,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    waveform: dict[str, Any] | None = None,
    spectral: dict[str, Any] | None = None,
    **kwargs: Any,
) -> None:
    """Display visual and audio representation of the frame.

    This method creates a comprehensive visualization with three plots:
    1. Time-domain waveform (top)
    2. Spectrogram (bottom-left)
    3. Frequency spectrum via Welch method (bottom-right)

    Args:
        normalize: Whether to normalize the audio data for playback.
            Default: True
        is_close: Whether to close the figure after displaying.
            Default: True
        fmin: Minimum frequency to display in the spectrogram (Hz).
            Default: 0
        fmax: Maximum frequency to display in the spectrogram (Hz).
            Default: Nyquist frequency (sampling_rate / 2)
        cmap: Colormap for the spectrogram.
            Default: 'jet'
        vmin: Minimum value for spectrogram color scale (dB).
            Auto-calculated if None.
        vmax: Maximum value for spectrogram color scale (dB).
            Auto-calculated if None.
        xlim: Time axis limits (seconds) for all time-based plots.
            Format: (start_time, end_time)
        ylim: Frequency axis limits (Hz) for frequency-based plots.
            Format: (min_freq, max_freq)
        Aw: Apply A-weighting to the frequency analysis.
            Default: False
        waveform: Additional configuration dict for waveform subplot.
            Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
        spectral: Additional configuration dict for spectral subplot.
            Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
        **kwargs: Deprecated parameters for backward compatibility only.
            - axis_config: Old configuration format (use waveform/spectral instead)
            - cbar_config: Old colorbar configuration (use vmin/vmax instead)

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Basic usage
        >>> cf.describe()
        >>>
        >>> # Custom frequency range
        >>> cf.describe(fmin=100, fmax=5000)
        >>>
        >>> # Custom color scale
        >>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
        >>>
        >>> # A-weighted analysis
        >>> cf.describe(Aw=True)
        >>>
        >>> # Custom time range
        >>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
        >>>
        >>> # Custom waveform subplot settings
        >>> cf.describe(waveform={"ylabel": "Custom Label"})
    """
    # Prepare kwargs with explicit parameters
    plot_kwargs: dict[str, Any] = {
        "fmin": fmin,
        "fmax": fmax,
        "cmap": cmap,
        "vmin": vmin,
        "vmax": vmax,
        "xlim": xlim,
        "ylim": ylim,
        "Aw": Aw,
        "waveform": waveform or {},
        "spectral": spectral or {},
    }
    # Merge with additional kwargs
    plot_kwargs.update(kwargs)

    if "axis_config" in plot_kwargs:
        logger.warning(
            "axis_config is retained for backward compatibility but will "
            "be deprecated in the future."
        )
        axis_config = plot_kwargs["axis_config"]
        if "time_plot" in axis_config:
            plot_kwargs["waveform"] = axis_config["time_plot"]
        if "freq_plot" in axis_config:
            if "xlim" in axis_config["freq_plot"]:
                vlim = axis_config["freq_plot"]["xlim"]
                plot_kwargs["vmin"] = vlim[0]
                plot_kwargs["vmax"] = vlim[1]
            if "ylim" in axis_config["freq_plot"]:
                ylim_config = axis_config["freq_plot"]["ylim"]
                plot_kwargs["ylim"] = ylim_config

    if "cbar_config" in plot_kwargs:
        logger.warning(
            "cbar_config is retained for backward compatibility but will "
            "be deprecated in the future."
        )
        cbar_config = plot_kwargs["cbar_config"]
        if "vmin" in cbar_config:
            plot_kwargs["vmin"] = cbar_config["vmin"]
        if "vmax" in cbar_config:
            plot_kwargs["vmax"] = cbar_config["vmax"]

    for ch in self:
        ax: Axes
        _ax = ch.plot("describe", title=f"{ch.label} {ch.labels[0]}", **plot_kwargs)
        if isinstance(_ax, Iterator):
            ax = next(iter(_ax))
        elif isinstance(_ax, Axes):
            ax = _ax
        else:
            raise TypeError(
                f"Unexpected type for plot result: {type(_ax)}. Expected Axes or Iterator[Axes]."  # noqa: E501
            )
        # display関数とAudioクラスを使用
        display(ax.figure)
        if is_close:
            plt.close(getattr(ax, "figure", None))
        display(Audio(ch.data, rate=ch.sampling_rate, normalize=normalize))
from_numpy(data, sampling_rate, label=None, metadata=None, ch_labels=None, ch_units=None) classmethod

Create a ChannelFrame from a NumPy array.

Parameters:

Name Type Description Default
data NDArrayReal

NumPy array containing channel data.

required
sampling_rate float

The sampling rate in Hz.

required
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
ch_labels list[str] | None

Labels for each channel.

None
ch_units list[str] | str | None

Units for each channel.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the NumPy data.

Source code in wandas/frames/channel.py
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
@classmethod
def from_numpy(
    cls,
    data: NDArrayReal,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    ch_labels: list[str] | None = None,
    ch_units: list[str] | str | None = None,
) -> "ChannelFrame":
    """Create a ChannelFrame from a NumPy array.

    Args:
        data: NumPy array containing channel data.
        sampling_rate: The sampling rate in Hz.
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        ch_labels: Labels for each channel.
        ch_units: Units for each channel.

    Returns:
        A new ChannelFrame containing the NumPy data.
    """
    if data.ndim == 1:
        data = data.reshape(1, -1)
    elif data.ndim > 2:
        raise ValueError(
            f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}"
        )

    # Convert NumPy array to dask array
    dask_data = da_from_array(data)
    cf = cls(
        data=dask_data,
        sampling_rate=sampling_rate,
        label=label or "numpy_data",
    )
    if metadata is not None:
        cf.metadata = metadata
    if ch_labels is not None:
        if len(ch_labels) != cf.n_channels:
            raise ValueError(
                "Number of channel labels does not match the number of channels"
            )
        for i in range(len(ch_labels)):
            cf._channel_metadata[i].label = ch_labels[i]
    if ch_units is not None:
        if isinstance(ch_units, str):
            ch_units = [ch_units] * cf.n_channels

        if len(ch_units) != cf.n_channels:
            raise ValueError(
                "Number of channel units does not match the number of channels"
            )
        for i in range(len(ch_units)):
            cf._channel_metadata[i].unit = ch_units[i]

    return cf
from_ndarray(array, sampling_rate, labels=None, unit=None, frame_label=None, metadata=None) classmethod

Create a ChannelFrame from a NumPy array.

This method is deprecated. Use from_numpy instead.

Parameters:

Name Type Description Default
array NDArrayReal

Signal data. Each row corresponds to a channel.

required
sampling_rate float

Sampling rate (Hz).

required
labels list[str] | None

Labels for each channel.

None
unit list[str] | str | None

Unit of the signal.

None
frame_label str | None

Label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data.

Source code in wandas/frames/channel.py
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
@classmethod
def from_ndarray(
    cls,
    array: NDArrayReal,
    sampling_rate: float,
    labels: list[str] | None = None,
    unit: list[str] | str | None = None,
    frame_label: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> "ChannelFrame":
    """Create a ChannelFrame from a NumPy array.

    This method is deprecated. Use from_numpy instead.

    Args:
        array: Signal data. Each row corresponds to a channel.
        sampling_rate: Sampling rate (Hz).
        labels: Labels for each channel.
        unit: Unit of the signal.
        frame_label: Label for the frame.
        metadata: Optional metadata dictionary.

    Returns:
        A new ChannelFrame containing the data.
    """
    # Redirect to from_numpy for compatibility
    # However, from_ndarray is deprecated
    logger.warning("from_ndarray is deprecated. Use from_numpy instead.")
    return cls.from_numpy(
        data=array,
        sampling_rate=sampling_rate,
        label=frame_label,
        metadata=metadata,
        ch_labels=labels,
        ch_units=unit,
    )
from_file(path, channel=None, start=None, end=None, chunk_size=None, ch_labels=None, time_column=0, delimiter=',', header=0) classmethod

Create a ChannelFrame from an audio file.

Parameters:

Name Type Description Default
path str | Path

Path to the audio file.

required
channel int | list[int] | None

Channel(s) to load.

None
start float | None

Start time in seconds.

None
end float | None

End time in seconds.

None
chunk_size int | None

Chunk size for processing. Specifies the splitting size for lazy processing.

None
ch_labels list[str] | None

Labels for each channel.

None
time_column int | str

For CSV files, index or name of the time column. Default is 0 (first column).

0
delimiter str

For CSV files, delimiter character. Default is ",".

','
header int | None

For CSV files, row number to use as header. Default is 0 (first row). Set to None if no header.

0

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the loaded audio data.

Raises:

Type Description
ValueError

If channel specification is invalid.

TypeError

If channel parameter type is invalid.

FileNotFoundError

If the file doesn't exist at the specified path. Error message includes absolute path, current directory, and troubleshooting suggestions.

Examples:

>>> # Load WAV file
>>> cf = ChannelFrame.from_file("audio.wav")
>>> # Load specific channels
>>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
>>> # Load CSV file
>>> cf = ChannelFrame.from_file(
...     "data.csv", time_column=0, delimiter=",", header=0
... )
Source code in wandas/frames/channel.py
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
@classmethod
def from_file(
    cls,
    path: str | Path,
    channel: int | list[int] | None = None,
    start: float | None = None,
    end: float | None = None,
    chunk_size: int | None = None,
    ch_labels: list[str] | None = None,
    # CSV-specific parameters
    time_column: int | str = 0,
    delimiter: str = ",",
    header: int | None = 0,
) -> "ChannelFrame":
    """Create a ChannelFrame from an audio file.

    Args:
        path: Path to the audio file.
        channel: Channel(s) to load.
        start: Start time in seconds.
        end: End time in seconds.
        chunk_size: Chunk size for processing.
            Specifies the splitting size for lazy processing.
        ch_labels: Labels for each channel.
        time_column: For CSV files, index or name of the time column.
            Default is 0 (first column).
        delimiter: For CSV files, delimiter character. Default is ",".
        header: For CSV files, row number to use as header.
            Default is 0 (first row). Set to None if no header.

    Returns:
        A new ChannelFrame containing the loaded audio data.

    Raises:
        ValueError: If channel specification is invalid.
        TypeError: If channel parameter type is invalid.
        FileNotFoundError: If the file doesn't exist at the specified path.
            Error message includes absolute path, current directory, and
            troubleshooting suggestions.

    Examples:
        >>> # Load WAV file
        >>> cf = ChannelFrame.from_file("audio.wav")
        >>> # Load specific channels
        >>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
        >>> # Load CSV file
        >>> cf = ChannelFrame.from_file(
        ...     "data.csv", time_column=0, delimiter=",", header=0
        ... )
    """
    from .channel import ChannelFrame

    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(
            f"Audio file not found\n"
            f"  Path: {path.absolute()}\n"
            f"  Current directory: {Path.cwd()}\n"
            f"Please check:\n"
            f"  - File path is correct\n"
            f"  - File exists at the specified location\n"
            f"  - You have read permissions for the file"
        )

    # Get file reader
    reader = get_file_reader(path)

    # Build kwargs for reader
    reader_kwargs: dict[str, Any] = {}
    if path.suffix.lower() == ".csv":
        reader_kwargs["time_column"] = time_column
        reader_kwargs["delimiter"] = delimiter
        if header is not None:
            reader_kwargs["header"] = header

    # Get file info
    info = reader.get_file_info(path, **reader_kwargs)
    sr = info["samplerate"]
    n_channels = info["channels"]
    n_frames = info["frames"]
    ch_labels = ch_labels or info.get("ch_labels", None)

    logger.debug(f"File info: sr={sr}, channels={n_channels}, frames={n_frames}")

    # Channel selection processing
    all_channels = list(range(n_channels))

    if channel is None:
        channels_to_load = all_channels
        logger.debug(f"Will load all channels: {channels_to_load}")
    elif isinstance(channel, int):
        if channel < 0 or channel >= n_channels:
            raise ValueError(
                f"Channel specification is out of range: {channel} (valid range: 0-{n_channels - 1})"  # noqa: E501
            )
        channels_to_load = [channel]
        logger.debug(f"Will load single channel: {channel}")
    elif isinstance(channel, list | tuple):
        for ch in channel:
            if ch < 0 or ch >= n_channels:
                raise ValueError(
                    f"Channel specification is out of range: {ch} (valid range: 0-{n_channels - 1})"  # noqa: E501
                )
        channels_to_load = list(channel)
        logger.debug(f"Will load specific channels: {channels_to_load}")
    else:
        raise TypeError("channel must be int, list, or None")

    # Index calculation
    start_idx = 0 if start is None else max(0, int(start * sr))
    end_idx = n_frames if end is None else min(n_frames, int(end * sr))
    frames_to_read = end_idx - start_idx

    logger.debug(
        f"Setting up lazy load from file={path}, frames={frames_to_read}, "
        f"start_idx={start_idx}, end_idx={end_idx}"
    )

    # Settings for lazy loading
    expected_shape = (len(channels_to_load), frames_to_read)

    # Define the loading function using the file reader
    def _load_audio() -> NDArrayReal:
        logger.debug(">>> EXECUTING DELAYED LOAD <<<")
        # Use the reader to get audio data with parameters
        out = reader.get_data(
            path, channels_to_load, start_idx, frames_to_read, **reader_kwargs
        )
        if not isinstance(out, np.ndarray):
            raise ValueError("Unexpected data type after reading file")
        return out

    logger.debug(
        f"Creating delayed dask task with expected shape: {expected_shape}"
    )

    # Create delayed operation
    delayed_data = dask_delayed(_load_audio)()
    logger.debug("Wrapping delayed function in dask array")

    # Create dask array from delayed computation
    dask_array = da_from_delayed(
        delayed_data, shape=expected_shape, dtype=np.float32
    )

    if chunk_size is not None:
        if chunk_size <= 0:
            raise ValueError("Chunk size must be a positive integer")
        logger.debug(f"Setting chunk size: {chunk_size} for sample axis")
        dask_array = dask_array.rechunk({0: -1, 1: chunk_size})

    logger.debug(
        "ChannelFrame setup complete - actual file reading will occur on compute()"  # noqa: E501
    )

    cf = ChannelFrame(
        data=dask_array,
        sampling_rate=sr,
        label=path.stem,
        metadata={
            "filename": str(path),
        },
    )
    if ch_labels is not None:
        if len(ch_labels) != len(cf):
            raise ValueError(
                "Number of channel labels does not match the number of specified channels"  # noqa: E501
            )
        for i in range(len(ch_labels)):
            cf._channel_metadata[i].label = ch_labels[i]
    return cf
read_wav(filename, labels=None) classmethod

Utility method to read a WAV file.

Parameters:

Name Type Description Default
filename str

Path to the WAV file.

required
labels list[str] | None

Labels to set for each channel.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data (lazy loading).

Source code in wandas/frames/channel.py
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
@classmethod
def read_wav(cls, filename: str, labels: list[str] | None = None) -> "ChannelFrame":
    """Utility method to read a WAV file.

    Args:
        filename: Path to the WAV file.
        labels: Labels to set for each channel.

    Returns:
        A new ChannelFrame containing the data (lazy loading).
    """
    from .channel import ChannelFrame

    cf = ChannelFrame.from_file(filename, ch_labels=labels)
    return cf
read_csv(filename, time_column=0, labels=None, delimiter=',', header=0) classmethod

Utility method to read a CSV file.

Parameters:

Name Type Description Default
filename str

Path to the CSV file.

required
time_column int | str

Index or name of the time column.

0
labels list[str] | None

Labels to set for each channel.

None
delimiter str

Delimiter character.

','
header int | None

Row number to use as header.

0

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data (lazy loading).

Examples:

>>> # Read CSV with default settings
>>> cf = ChannelFrame.read_csv("data.csv")
>>> # Read CSV with custom delimiter
>>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
>>> # Read CSV without header
>>> cf = ChannelFrame.read_csv("data.csv", header=None)
Source code in wandas/frames/channel.py
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
@classmethod
def read_csv(
    cls,
    filename: str,
    time_column: int | str = 0,
    labels: list[str] | None = None,
    delimiter: str = ",",
    header: int | None = 0,
) -> "ChannelFrame":
    """Utility method to read a CSV file.

    Args:
        filename: Path to the CSV file.
        time_column: Index or name of the time column.
        labels: Labels to set for each channel.
        delimiter: Delimiter character.
        header: Row number to use as header.

    Returns:
        A new ChannelFrame containing the data (lazy loading).

    Examples:
        >>> # Read CSV with default settings
        >>> cf = ChannelFrame.read_csv("data.csv")
        >>> # Read CSV with custom delimiter
        >>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
        >>> # Read CSV without header
        >>> cf = ChannelFrame.read_csv("data.csv", header=None)
    """
    from .channel import ChannelFrame

    cf = ChannelFrame.from_file(
        filename,
        ch_labels=labels,
        time_column=time_column,
        delimiter=delimiter,
        header=header,
    )
    return cf
to_wav(path, format=None)

Save the audio data to a WAV file.

Parameters:

Name Type Description Default
path str | Path

Path to save the file.

required
format str | None

File format. If None, determined from file extension.

None
Source code in wandas/frames/channel.py
942
943
944
945
946
947
948
949
950
951
def to_wav(self, path: str | Path, format: str | None = None) -> None:
    """Save the audio data to a WAV file.

    Args:
        path: Path to save the file.
        format: File format. If None, determined from file extension.
    """
    from wandas.io.wav_io import write_wav

    write_wav(str(path), self, format=format)
save(path, *, format='hdf5', compress='gzip', overwrite=False, dtype=None)

Save the ChannelFrame to a WDF (Wandas Data File) format.

This saves the complete frame including all channel data and metadata in a format that can be loaded back with full fidelity.

Parameters:

Name Type Description Default
path str | Path

Path to save the file. '.wdf' extension will be added if not present.

required
format str

Format to use (currently only 'hdf5' is supported)

'hdf5'
compress str | None

Compression method ('gzip' by default, None for no compression)

'gzip'
overwrite bool

Whether to overwrite existing file

False
dtype str | dtype[Any] | None

Optional data type conversion before saving (e.g. 'float32')

None

Raises:

Type Description
FileExistsError

If the file exists and overwrite=False.

NotImplementedError

For unsupported formats.

Example

cf = ChannelFrame.read_wav("audio.wav") cf.save("audio_analysis.wdf")

Source code in wandas/frames/channel.py
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
def save(
    self,
    path: str | Path,
    *,
    format: str = "hdf5",
    compress: str | None = "gzip",
    overwrite: bool = False,
    dtype: str | np.dtype[Any] | None = None,
) -> None:
    """Save the ChannelFrame to a WDF (Wandas Data File) format.

    This saves the complete frame including all channel data and metadata
    in a format that can be loaded back with full fidelity.

    Args:
        path: Path to save the file. '.wdf' extension will be added if not present.
        format: Format to use (currently only 'hdf5' is supported)
        compress: Compression method ('gzip' by default, None for no compression)
        overwrite: Whether to overwrite existing file
        dtype: Optional data type conversion before saving (e.g. 'float32')

    Raises:
        FileExistsError: If the file exists and overwrite=False.
        NotImplementedError: For unsupported formats.

    Example:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> cf.save("audio_analysis.wdf")
    """
    from ..io.wdf_io import save as wdf_save

    wdf_save(
        self,
        path,
        format=format,
        compress=compress,
        overwrite=overwrite,
        dtype=dtype,
    )
load(path, *, format='hdf5') classmethod

Load a ChannelFrame from a WDF (Wandas Data File) file.

This loads data saved with the save() method, preserving all channel data, metadata, labels, and units.

Parameters:

Name Type Description Default
path str | Path

Path to the WDF file

required
format str

Format of the file (currently only 'hdf5' is supported)

'hdf5'

Returns:

Type Description
ChannelFrame

A new ChannelFrame with all data and metadata loaded

Raises:

Type Description
FileNotFoundError

If the file doesn't exist

NotImplementedError

For unsupported formats

Example

cf = ChannelFrame.load("audio_analysis.wdf")

Source code in wandas/frames/channel.py
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
@classmethod
def load(cls, path: str | Path, *, format: str = "hdf5") -> "ChannelFrame":
    """Load a ChannelFrame from a WDF (Wandas Data File) file.

    This loads data saved with the save() method, preserving all channel data,
    metadata, labels, and units.

    Args:
        path: Path to the WDF file
        format: Format of the file (currently only 'hdf5' is supported)

    Returns:
        A new ChannelFrame with all data and metadata loaded

    Raises:
        FileNotFoundError: If the file doesn't exist
        NotImplementedError: For unsupported formats

    Example:
        >>> cf = ChannelFrame.load("audio_analysis.wdf")
    """
    from ..io.wdf_io import load as wdf_load

    return wdf_load(path, format=format)
add_channel(data, label=None, align='strict', suffix_on_dup=None, inplace=False)

Add a new channel to the frame.

Parameters:

Name Type Description Default
data ndarray[Any, Any] | Array | ChannelFrame

Data to add as a new channel. Can be: - numpy array (1D or 2D) - dask array (1D or 2D) - ChannelFrame (channels will be added)

required
label str | None

Label for the new channel. If None, generates a default label. Ignored when data is a ChannelFrame (uses its channel labels).

None
align str

How to handle length mismatches: - "strict": Raise error if lengths don't match - "pad": Pad shorter data with zeros - "truncate": Truncate longer data to match

'strict'
suffix_on_dup str | None

Suffix to add to duplicate labels. If None, raises error.

None
inplace bool

If True, modifies the frame in place. Otherwise returns a new frame.

False

Returns:

Type Description
ChannelFrame

Modified ChannelFrame (self if inplace=True, new frame otherwise).

Raises:

Type Description
ValueError

If data length doesn't match and align="strict", or if label is duplicate and suffix_on_dup is None.

TypeError

If data type is not supported.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Add a numpy array as a new channel
>>> new_data = np.sin(2 * np.pi * 440 * cf.time)
>>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
>>> # Add another ChannelFrame's channels
>>> cf2 = ChannelFrame.read_wav("audio2.wav")
>>> cf_combined = cf.add_channel(cf2)
Source code in wandas/frames/channel.py
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
def add_channel(
    self,
    data: "np.ndarray[Any, Any] | DaskArray | ChannelFrame",
    label: str | None = None,
    align: str = "strict",
    suffix_on_dup: str | None = None,
    inplace: bool = False,
) -> "ChannelFrame":
    """Add a new channel to the frame.

    Args:
        data: Data to add as a new channel. Can be:
            - numpy array (1D or 2D)
            - dask array (1D or 2D)
            - ChannelFrame (channels will be added)
        label: Label for the new channel. If None, generates a default label.
            Ignored when data is a ChannelFrame (uses its channel labels).
        align: How to handle length mismatches:
            - "strict": Raise error if lengths don't match
            - "pad": Pad shorter data with zeros
            - "truncate": Truncate longer data to match
        suffix_on_dup: Suffix to add to duplicate labels. If None, raises error.
        inplace: If True, modifies the frame in place.
            Otherwise returns a new frame.

    Returns:
        Modified ChannelFrame (self if inplace=True, new frame otherwise).

    Raises:
        ValueError: If data length doesn't match and align="strict",
            or if label is duplicate and suffix_on_dup is None.
        TypeError: If data type is not supported.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Add a numpy array as a new channel
        >>> new_data = np.sin(2 * np.pi * 440 * cf.time)
        >>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
        >>> # Add another ChannelFrame's channels
        >>> cf2 = ChannelFrame.read_wav("audio2.wav")
        >>> cf_combined = cf.add_channel(cf2)
    """
    # ndarray/dask/同型Frame対応
    if isinstance(data, ChannelFrame):
        if self.sampling_rate != data.sampling_rate:
            raise ValueError("sampling_rate不一致")
        if data.n_samples != self.n_samples:
            if align == "pad":
                pad_len = self.n_samples - data.n_samples
                arr = data._data
                if pad_len > 0:
                    arr = concatenate(
                        [
                            arr,
                            from_array(
                                np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                            ),
                        ],
                        axis=1,
                    )
                else:
                    arr = arr[:, : self.n_samples]
            elif align == "truncate":
                arr = data._data[:, : self.n_samples]
                if arr.shape[1] < self.n_samples:
                    pad_len = self.n_samples - arr.shape[1]
                    arr = concatenate(
                        [
                            arr,
                            from_array(
                                np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                            ),
                        ],
                        axis=1,
                    )
            else:
                raise ValueError("データ長不一致: align指定を確認")
        else:
            arr = data._data
        labels = [ch.label for ch in self._channel_metadata]
        new_labels = []
        new_metadata_list = []
        for chmeta in data._channel_metadata:
            new_label = chmeta.label
            if new_label in labels or new_label in new_labels:
                if suffix_on_dup:
                    new_label += suffix_on_dup
                else:
                    raise ValueError(f"label重複: {new_label}")
            new_labels.append(new_label)
            # Copy the entire channel_metadata and update only the label
            new_ch_meta = chmeta.model_copy(deep=True)
            new_ch_meta.label = new_label
            new_metadata_list.append(new_ch_meta)
        new_data = concatenate([self._data, arr], axis=0)

        new_chmeta = self._channel_metadata + new_metadata_list
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )
    if isinstance(data, np.ndarray):
        arr = from_array(data.reshape(1, -1))
    elif isinstance(data, DaskArray):
        arr = data[None, ...] if data.ndim == 1 else data
        if arr.shape[0] != 1:
            arr = arr.reshape((1, -1))
    else:
        raise TypeError("add_channel: ndarray/dask/同型Frameのみ対応")
    if arr.shape[1] != self.n_samples:
        if align == "pad":
            pad_len = self.n_samples - arr.shape[1]
            if pad_len > 0:
                arr = concatenate(
                    [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                    axis=1,
                )
            else:
                arr = arr[:, : self.n_samples]
        elif align == "truncate":
            arr = arr[:, : self.n_samples]
            if arr.shape[1] < self.n_samples:
                pad_len = self.n_samples - arr.shape[1]
                arr = concatenate(
                    [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                    axis=1,
                )
        else:
            raise ValueError("データ長不一致: align指定を確認")
    labels = [ch.label for ch in self._channel_metadata]
    new_label = label or f"ch{len(labels)}"
    if new_label in labels:
        if suffix_on_dup:
            new_label += suffix_on_dup
        else:
            raise ValueError("label重複")
    new_data = concatenate([self._data, arr], axis=0)
    from ..core.metadata import ChannelMetadata

    new_chmeta = self._channel_metadata + [ChannelMetadata(label=new_label)]
    if inplace:
        self._data = new_data
        self._channel_metadata = new_chmeta
        return self
    else:
        return ChannelFrame(
            data=new_data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=new_chmeta,
            previous=self,
        )
remove_channel(key, inplace=False)
Source code in wandas/frames/channel.py
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
def remove_channel(self, key: int | str, inplace: bool = False) -> "ChannelFrame":
    if isinstance(key, int):
        if not (0 <= key < self.n_channels):
            raise IndexError(f"index {key} out of range")
        idx = key
    else:
        labels = [ch.label for ch in self._channel_metadata]
        if key not in labels:
            raise KeyError(f"label {key} not found")
        idx = labels.index(key)
    new_data = self._data[[i for i in range(self.n_channels) if i != idx], :]
    new_chmeta = [ch for i, ch in enumerate(self._channel_metadata) if i != idx]
    if inplace:
        self._data = new_data
        self._channel_metadata = new_chmeta
        return self
    else:
        return ChannelFrame(
            data=new_data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=new_chmeta,
            previous=self,
        )

RoughnessFrame

Bases: BaseFrame[NDArrayReal]

Frame for detailed roughness analysis with Bark-band information.

This frame contains specific roughness (R_spec) data organized by Bark frequency bands over time, calculated using the Daniel & Weber (1997) method.

The relationship between total roughness and specific roughness follows: R = 0.25 * sum(R_spec, axis=bark_bands)

Parameters

data : da.Array Specific roughness data with shape: - (n_bark_bands, n_time) for mono signals - (n_channels, n_bark_bands, n_time) for multi-channel signals where n_bark_bands is always 47. sampling_rate : float Sampling rate of the roughness time series in Hz. For overlap=0.5, this is approximately 10 Hz (100ms hop). For overlap=0.0, this is approximately 5 Hz (200ms hop). bark_axis : NDArrayReal Bark frequency axis with 47 values from 0.5 to 23.5 Bark. overlap : float Overlap coefficient used in the calculation (0.0 to 1.0). label : str, optional Frame label. Defaults to "roughness_spec". metadata : dict, optional Additional metadata. operation_history : list[dict], optional History of operations applied to this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel. previous : BaseFrame, optional Reference to the previous frame in the processing chain.

Attributes

bark_axis : NDArrayReal Frequency axis in Bark scale. n_bark_bands : int Number of Bark bands (always 47). n_time_points : int Number of time points. time : NDArrayReal Time axis based on sampling rate. overlap : float Overlap coefficient used (0.0 to 1.0).

Examples

Create a roughness frame from a signal:

import wandas as wd signal = wd.read_wav("motor.wav") roughness_spec = signal.roughness_dw_spec(overlap=0.5)

Plot Bark-Time heatmap

roughness_spec.plot()

Find dominant Bark band

dominant_idx = roughness_spec.data.mean(axis=1).argmax() dominant_bark = roughness_spec.bark_axis[dominant_idx] print(f"Dominant frequency: {dominant_bark:.1f} Bark")

Extract specific Bark band

bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0)) roughness_at_10bark = roughness_spec.data[bark_10_idx, :]

Notes

The Daniel & Weber (1997) roughness model calculates specific roughness for 47 critical bands (Bark scale) over time, then integrates them to produce the total roughness:

.. math:: R = 0.25 \sum_{i=1}^{47} R'_i

where R'_i is the specific roughness in the i-th Bark band.

References

.. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model". Acta Acustica united with Acustica, 83(1), 113-123.

Source code in wandas/frames/roughness.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
class RoughnessFrame(BaseFrame[NDArrayReal]):
    """
    Frame for detailed roughness analysis with Bark-band information.

    This frame contains specific roughness (R_spec) data organized by
    Bark frequency bands over time, calculated using the Daniel & Weber (1997)
    method.

    The relationship between total roughness and specific roughness follows:
    R = 0.25 * sum(R_spec, axis=bark_bands)

    Parameters
    ----------
    data : da.Array
        Specific roughness data with shape:
        - (n_bark_bands, n_time) for mono signals
        - (n_channels, n_bark_bands, n_time) for multi-channel signals
        where n_bark_bands is always 47.
    sampling_rate : float
        Sampling rate of the roughness time series in Hz.
        For overlap=0.5, this is approximately 10 Hz (100ms hop).
        For overlap=0.0, this is approximately 5 Hz (200ms hop).
    bark_axis : NDArrayReal
        Bark frequency axis with 47 values from 0.5 to 23.5 Bark.
    overlap : float
        Overlap coefficient used in the calculation (0.0 to 1.0).
    label : str, optional
        Frame label. Defaults to "roughness_spec".
    metadata : dict, optional
        Additional metadata.
    operation_history : list[dict], optional
        History of operations applied to this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel.
    previous : BaseFrame, optional
        Reference to the previous frame in the processing chain.

    Attributes
    ----------
    bark_axis : NDArrayReal
        Frequency axis in Bark scale.
    n_bark_bands : int
        Number of Bark bands (always 47).
    n_time_points : int
        Number of time points.
    time : NDArrayReal
        Time axis based on sampling rate.
    overlap : float
        Overlap coefficient used (0.0 to 1.0).

    Examples
    --------
    Create a roughness frame from a signal:

    >>> import wandas as wd
    >>> signal = wd.read_wav("motor.wav")
    >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
    >>>
    >>> # Plot Bark-Time heatmap
    >>> roughness_spec.plot()
    >>>
    >>> # Find dominant Bark band
    >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
    >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
    >>> print(f"Dominant frequency: {dominant_bark:.1f} Bark")
    >>>
    >>> # Extract specific Bark band
    >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
    >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]

    Notes
    -----
    The Daniel & Weber (1997) roughness model calculates specific roughness
    for 47 critical bands (Bark scale) over time, then integrates them to
    produce the total roughness:

    .. math::
        R = 0.25 \\sum_{i=1}^{47} R'_i

    where R'_i is the specific roughness in the i-th Bark band.

    References
    ----------
    .. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
           Implementation of an optimized model". Acta Acustica united with
           Acustica, 83(1), 113-123.
    """

    def __init__(
        self,
        data: da.Array,
        sampling_rate: float,
        bark_axis: NDArrayReal,
        overlap: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> None:
        """Initialize a RoughnessFrame."""
        # Validate dimensions
        if data.ndim not in (2, 3):
            raise ValueError(
                f"Data must be 2D or 3D (mono or multi-channel), got {data.ndim}D"
            )

        # Validate Bark bands
        if data.shape[-2] != 47:
            raise ValueError(
                f"Expected 47 Bark bands, got {data.shape[-2]} "
                f"(data shape: {data.shape})"
            )

        if len(bark_axis) != 47:
            raise ValueError(f"bark_axis must have 47 elements, got {len(bark_axis)}")

        # Validate overlap
        if not 0.0 <= overlap <= 1.0:
            raise ValueError(f"overlap must be in [0.0, 1.0], got {overlap}")

        # Store Bark-specific attributes
        self._bark_axis = bark_axis
        self._overlap = overlap

        # Initialize base frame
        metadata = metadata or {}
        metadata["overlap"] = overlap

        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label or "roughness_spec",
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def data(self) -> NDArrayReal:
        """
        Returns the computed data without squeezing.

        For RoughnessFrame, even mono signals have 2D shape (47, n_time)
        so we don't squeeze the channel dimension.

        Returns
        -------
        NDArrayReal
            Computed data array.
        """
        return self.compute()

    @property
    def bark_axis(self) -> NDArrayReal:
        """
        Bark frequency axis.

        Returns
        -------
        NDArrayReal
            Array of 47 Bark values from 0.5 to 23.5 Bark.
        """
        return self._bark_axis

    @property
    def n_bark_bands(self) -> int:
        """
        Number of Bark bands.

        Returns
        -------
        int
            Always 47 for the Daniel & Weber model.
        """
        return 47

    @property
    def n_time_points(self) -> int:
        """
        Number of time points in the roughness time series.

        Returns
        -------
        int
            Number of time frames in the analysis.
        """
        return int(self._data.shape[-1])

    @property
    def time(self) -> NDArrayReal:
        """
        Time axis based on sampling rate.

        Returns
        -------
        NDArrayReal
            Time values in seconds for each frame.
        """
        return np.arange(self.n_time_points) / self.sampling_rate

    @property
    def overlap(self) -> float:
        """
        Overlap coefficient used in the calculation.

        Returns
        -------
        float
            Overlap value between 0.0 and 1.0.
        """
        return self._overlap

    @property
    def _n_channels(self) -> int:
        """
        Return the number of channels.

        Returns
        -------
        int
            Number of channels. For 2D data (mono), returns 1.
        """
        if self._data.ndim == 2:
            return 1
        return int(self._data.shape[0])

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Provide additional initialization arguments for RoughnessFrame.

        Returns
        -------
        dict
            Dictionary containing bark_axis and overlap
        """
        return {
            "bark_axis": self._bark_axis,
            "overlap": self._overlap,
        }

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """DataFrame index is not supported for RoughnessFrame."""
        raise NotImplementedError(
            "DataFrame index is not supported for RoughnessFrame."
        )

    def to_dataframe(self) -> "pd.DataFrame":
        """DataFrame conversion is not supported for RoughnessFrame.

        RoughnessFrame contains 3D data (channels, bark_bands, time_frames)
        which cannot be directly converted to a 2D DataFrame.

        Raises
        ------
        NotImplementedError
            Always raised as DataFrame conversion is not supported.
        """
        raise NotImplementedError(
            "DataFrame conversion is not supported for RoughnessFrame."
        )

    def _binary_op(
        self,
        other: Union["RoughnessFrame", int, float, NDArrayReal, da.Array],
        op: "Callable[[da.Array, Any], da.Array]",
        symbol: str,
    ) -> "RoughnessFrame":
        """
        Common implementation for binary operations.

        Parameters
        ----------
        other : RoughnessFrame, int, float, NDArrayReal, or da.Array
            Right operand for the operation.
        op : Callable
            Function to execute the operation.
        symbol : str
            Symbolic representation of the operation.

        Returns
        -------
        RoughnessFrame
            A new RoughnessFrame with the operation result.

        Raises
        ------
        ValueError
            If sampling rates don't match or shapes are incompatible.
        """
        logger.debug(f"Setting up {symbol} operation (lazy)")

        # Handle metadata and operation_history
        metadata = self.metadata.copy() if self.metadata else {}
        operation_history = (
            self.operation_history.copy() if self.operation_history else []
        )

        # Check if other is a RoughnessFrame
        if isinstance(other, RoughnessFrame):
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    f"Sampling rates do not match: {self.sampling_rate} vs "
                    f"{other.sampling_rate}"
                )

            if self._data.shape != other._data.shape:
                raise ValueError(
                    f"Shape mismatch: {self._data.shape} vs {other._data.shape}"
                )

            # Apply operation
            result_data = op(self._data, other._data)

            # Update operation history
            operation_history.append(
                {"name": f"binary_op_{symbol}", "params": {"other": "RoughnessFrame"}}
            )

        else:
            # Scalar or array operation
            if isinstance(other, np.ndarray):
                other = da.from_array(other, chunks=self._data.chunks)

            result_data = op(self._data, other)

            operation_history.append(
                {"name": f"binary_op_{symbol}", "params": {"other": str(type(other))}}
            )

        # Create new instance
        return RoughnessFrame(
            data=result_data,
            sampling_rate=self.sampling_rate,
            bark_axis=self._bark_axis,
            overlap=self._overlap,
            label=self.label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=self._channel_metadata,
            previous=self,
        )

    def _apply_operation_impl(
        self, operation_name: str, **params: Any
    ) -> "RoughnessFrame":
        """
        Implementation of operation application.

        Note: RoughnessFrame is typically a terminal node in processing chains.
        Most operations are not directly applicable to spectral roughness data.

        Parameters
        ----------
        operation_name : str
            Name of the operation to apply.
        **params : Any
            Operation parameters.

        Returns
        -------
        RoughnessFrame
            A new RoughnessFrame with the operation applied.

        Raises
        ------
        NotImplementedError
            As most operations are not applicable to roughness spectrograms.
        """
        raise NotImplementedError(
            f"Operation '{operation_name}' is not supported for RoughnessFrame. "
            "RoughnessFrame is typically a terminal node in the processing chain."
        )

    def plot(
        self,
        plot_type: str = "heatmap",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        cmap: str = "viridis",
        vmin: float | None = None,
        vmax: float | None = None,
        xlabel: str = "Time [s]",
        ylabel: str = "Frequency [Bark]",
        colorbar_label: str = "Specific Roughness [Asper/Bark]",
        **kwargs: Any,
    ) -> "Axes":
        """
        Plot Bark-Time-Roughness heatmap.

        For multi-channel signals, the mean across channels is plotted.

        Parameters
        ----------
        ax : Axes, optional
            Matplotlib axes to plot on. If None, a new figure is created.
        title : str, optional
            Plot title. If None, a default title is used.
        cmap : str, default="viridis"
            Colormap name for the heatmap.
        vmin, vmax : float, optional
            Color scale limits. If None, automatic scaling is used.
        xlabel : str, default="Time [s]"
            Label for the x-axis.
        ylabel : str, default="Frequency [Bark]"
            Label for the y-axis.
        colorbar_label : str, default="Specific Roughness [Asper/Bark]"
            Label for the colorbar.
        **kwargs : Any
            Additional keyword arguments passed to pcolormesh.

        Returns
        -------
        Axes
            The matplotlib axes object containing the plot.

        Examples
        --------
        >>> import wandas as wd
        >>> signal = wd.read_wav("motor.wav")
        >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
        >>> roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")
        """
        import matplotlib.pyplot as plt

        if ax is None:
            _, ax = plt.subplots(figsize=(10, 6))

        # Select data to plot (first channel for mono, mean for multi-channel)
        # self._data is Dask array, self.data is computed NumPy array
        computed_data = self.compute()

        if computed_data.ndim == 2:
            # Mono: (47, n_time)
            data_to_plot = computed_data
        else:
            # Multi-channel: (n_channels, 47, n_time) -> average to (47, n_time)
            data_to_plot = computed_data.mean(axis=0)

        # Create heatmap
        im = ax.pcolormesh(
            self.time,
            self.bark_axis,
            data_to_plot,
            shading="auto",
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            **kwargs,
        )

        # Labels and title
        ax.set_xlabel(xlabel)
        ax.set_ylabel(ylabel)
        if title is None:
            title = f"Roughness Spectrogram (overlap={self._overlap})"
        ax.set_title(title)

        # Colorbar
        plt.colorbar(im, ax=ax, label=colorbar_label)

        return ax
Attributes
data property

Returns the computed data without squeezing.

For RoughnessFrame, even mono signals have 2D shape (47, n_time) so we don't squeeze the channel dimension.

Returns

NDArrayReal Computed data array.

bark_axis property

Bark frequency axis.

Returns

NDArrayReal Array of 47 Bark values from 0.5 to 23.5 Bark.

n_bark_bands property

Number of Bark bands.

Returns

int Always 47 for the Daniel & Weber model.

n_time_points property

Number of time points in the roughness time series.

Returns

int Number of time frames in the analysis.

time property

Time axis based on sampling rate.

Returns

NDArrayReal Time values in seconds for each frame.

overlap property

Overlap coefficient used in the calculation.

Returns

float Overlap value between 0.0 and 1.0.

Functions
__init__(data, sampling_rate, bark_axis, overlap, label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)

Initialize a RoughnessFrame.

Source code in wandas/frames/roughness.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def __init__(
    self,
    data: da.Array,
    sampling_rate: float,
    bark_axis: NDArrayReal,
    overlap: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> None:
    """Initialize a RoughnessFrame."""
    # Validate dimensions
    if data.ndim not in (2, 3):
        raise ValueError(
            f"Data must be 2D or 3D (mono or multi-channel), got {data.ndim}D"
        )

    # Validate Bark bands
    if data.shape[-2] != 47:
        raise ValueError(
            f"Expected 47 Bark bands, got {data.shape[-2]} "
            f"(data shape: {data.shape})"
        )

    if len(bark_axis) != 47:
        raise ValueError(f"bark_axis must have 47 elements, got {len(bark_axis)}")

    # Validate overlap
    if not 0.0 <= overlap <= 1.0:
        raise ValueError(f"overlap must be in [0.0, 1.0], got {overlap}")

    # Store Bark-specific attributes
    self._bark_axis = bark_axis
    self._overlap = overlap

    # Initialize base frame
    metadata = metadata or {}
    metadata["overlap"] = overlap

    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label or "roughness_spec",
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
to_dataframe()

DataFrame conversion is not supported for RoughnessFrame.

RoughnessFrame contains 3D data (channels, bark_bands, time_frames) which cannot be directly converted to a 2D DataFrame.

Raises

NotImplementedError Always raised as DataFrame conversion is not supported.

Source code in wandas/frames/roughness.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def to_dataframe(self) -> "pd.DataFrame":
    """DataFrame conversion is not supported for RoughnessFrame.

    RoughnessFrame contains 3D data (channels, bark_bands, time_frames)
    which cannot be directly converted to a 2D DataFrame.

    Raises
    ------
    NotImplementedError
        Always raised as DataFrame conversion is not supported.
    """
    raise NotImplementedError(
        "DataFrame conversion is not supported for RoughnessFrame."
    )
plot(plot_type='heatmap', ax=None, title=None, cmap='viridis', vmin=None, vmax=None, xlabel='Time [s]', ylabel='Frequency [Bark]', colorbar_label='Specific Roughness [Asper/Bark]', **kwargs)

Plot Bark-Time-Roughness heatmap.

For multi-channel signals, the mean across channels is plotted.

Parameters

ax : Axes, optional Matplotlib axes to plot on. If None, a new figure is created. title : str, optional Plot title. If None, a default title is used. cmap : str, default="viridis" Colormap name for the heatmap. vmin, vmax : float, optional Color scale limits. If None, automatic scaling is used. xlabel : str, default="Time [s]" Label for the x-axis. ylabel : str, default="Frequency [Bark]" Label for the y-axis. colorbar_label : str, default="Specific Roughness [Asper/Bark]" Label for the colorbar. **kwargs : Any Additional keyword arguments passed to pcolormesh.

Returns

Axes The matplotlib axes object containing the plot.

Examples

import wandas as wd signal = wd.read_wav("motor.wav") roughness_spec = signal.roughness_dw_spec(overlap=0.5) roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")

Source code in wandas/frames/roughness.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
def plot(
    self,
    plot_type: str = "heatmap",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    cmap: str = "viridis",
    vmin: float | None = None,
    vmax: float | None = None,
    xlabel: str = "Time [s]",
    ylabel: str = "Frequency [Bark]",
    colorbar_label: str = "Specific Roughness [Asper/Bark]",
    **kwargs: Any,
) -> "Axes":
    """
    Plot Bark-Time-Roughness heatmap.

    For multi-channel signals, the mean across channels is plotted.

    Parameters
    ----------
    ax : Axes, optional
        Matplotlib axes to plot on. If None, a new figure is created.
    title : str, optional
        Plot title. If None, a default title is used.
    cmap : str, default="viridis"
        Colormap name for the heatmap.
    vmin, vmax : float, optional
        Color scale limits. If None, automatic scaling is used.
    xlabel : str, default="Time [s]"
        Label for the x-axis.
    ylabel : str, default="Frequency [Bark]"
        Label for the y-axis.
    colorbar_label : str, default="Specific Roughness [Asper/Bark]"
        Label for the colorbar.
    **kwargs : Any
        Additional keyword arguments passed to pcolormesh.

    Returns
    -------
    Axes
        The matplotlib axes object containing the plot.

    Examples
    --------
    >>> import wandas as wd
    >>> signal = wd.read_wav("motor.wav")
    >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
    >>> roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")
    """
    import matplotlib.pyplot as plt

    if ax is None:
        _, ax = plt.subplots(figsize=(10, 6))

    # Select data to plot (first channel for mono, mean for multi-channel)
    # self._data is Dask array, self.data is computed NumPy array
    computed_data = self.compute()

    if computed_data.ndim == 2:
        # Mono: (47, n_time)
        data_to_plot = computed_data
    else:
        # Multi-channel: (n_channels, 47, n_time) -> average to (47, n_time)
        data_to_plot = computed_data.mean(axis=0)

    # Create heatmap
    im = ax.pcolormesh(
        self.time,
        self.bark_axis,
        data_to_plot,
        shading="auto",
        cmap=cmap,
        vmin=vmin,
        vmax=vmax,
        **kwargs,
    )

    # Labels and title
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    if title is None:
        title = f"Roughness Spectrogram (overlap={self._overlap})"
    ax.set_title(title)

    # Colorbar
    plt.colorbar(im, ax=ax, label=colorbar_label)

    return ax

Modules

channel

Attributes
logger = logging.getLogger(__name__) module-attribute
dask_delayed = dask.delayed module-attribute
da_from_delayed = da.from_delayed module-attribute
da_from_array = da.from_array module-attribute
S = TypeVar('S', bound='BaseFrame[Any]') module-attribute
Classes
ChannelFrame

Bases: BaseFrame[NDArrayReal], ChannelProcessingMixin, ChannelTransformMixin

Channel-based data frame for handling audio signals and time series data.

This frame represents channel-based data such as audio signals and time series data, with each channel containing data samples in the time domain.

Source code in wandas/frames/channel.py
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
class ChannelFrame(
    BaseFrame[NDArrayReal], ChannelProcessingMixin, ChannelTransformMixin
):
    """Channel-based data frame for handling audio signals and time series data.

    This frame represents channel-based data such as audio signals and time series data,
    with each channel containing data samples in the time domain.
    """

    def __init__(
        self,
        data: DaskArray,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> None:
        """Initialize a ChannelFrame.

        Args:
            data: Dask array containing channel data.
            Shape should be (n_channels, n_samples).
            sampling_rate: The sampling rate of the data in Hz.
                Must be a positive value.
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            operation_history: History of operations applied to the frame.
            channel_metadata: Metadata for each channel.
            previous: Reference to the previous frame in the processing chain.

        Raises:
            ValueError: If data has more than 2 dimensions, or if
                sampling_rate is not positive.
        """
        # Validate sampling rate
        validate_sampling_rate(sampling_rate)

        # Validate and reshape data
        if data.ndim == 1:
            data = da.reshape(data, (1, -1))
        elif data.ndim > 2:
            raise ValueError(
                f"Invalid data shape for ChannelFrame\n"
                f"  Got: {data.shape} ({data.ndim}D)\n"
                f"  Expected: 1D (samples,) or 2D (channels, samples)\n"
                f"If you have a 1D array, it will be automatically reshaped to\n"
                f"  (1, n_samples).\n"
                f"For higher-dimensional data, reshape it before creating\n"
                f"  ChannelFrame:\n"
                f"  Example: data.reshape(n_channels, -1)"
            )
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def _n_channels(self) -> int:
        """Returns the number of channels."""
        return int(self._data.shape[-2])

    @property
    def time(self) -> NDArrayReal:
        """Get time array for the signal.

        The time array represents the start time of each sample, calculated as
        sample_index / sampling_rate. This provides a uniform, evenly-spaced
        time axis that is consistent across all frame types in wandas.

        For frames resulting from windowed analysis operations (e.g., FFT,
        loudness, roughness), each time point corresponds to the start of
        the analysis window, not the center. This differs from some libraries
        (e.g., MoSQITo) which use window center times, but does not affect
        the calculated values themselves.

        Returns:
            Array of time points in seconds, starting from 0.0.

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> time = signal.time
            >>> print(f"Duration: {time[-1]:.3f}s")
            >>> print(f"Time step: {time[1] - time[0]:.6f}s")
        """
        return np.arange(self.n_samples) / self.sampling_rate

    @property
    def n_samples(self) -> int:
        """Returns the number of samples."""
        n: int = self._data.shape[-1]
        return n

    @property
    def duration(self) -> float:
        """Returns the duration in seconds."""
        return self.n_samples / self.sampling_rate

    @property
    def rms(self) -> NDArrayReal:
        """Calculate RMS (Root Mean Square) value for each channel.

        Returns:
            Array of RMS values, one per channel.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> rms_values = cf.rms
            >>> print(f"RMS values: {rms_values}")
            >>> # Select channels with RMS > threshold
            >>> active_channels = cf[cf.rms > 0.5]
        """
        # Convert to a concrete NumPy ndarray to satisfy numpy.mean typing
        # and to ensure dask arrays are materialized for this operation.
        rms_values = da.sqrt((self._data**2).mean(axis=1))
        return np.array(rms_values.compute())

    def info(self) -> None:
        """Display comprehensive information about the ChannelFrame.

        This method prints a summary of the frame's properties including:
        - Number of channels
        - Sampling rate
        - Duration
        - Number of samples
        - Channel labels

        This is a convenience method to view all key properties at once,
        similar to pandas DataFrame.info().

        Examples
        --------
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> cf.info()
        Channels: 2
        Sampling rate: 44100 Hz
        Duration: 1.0 s
        Samples: 44100
        Channel labels: ['ch0', 'ch1']
        """
        print("ChannelFrame Information:")
        print(f"  Channels: {self.n_channels}")
        print(f"  Sampling rate: {self.sampling_rate} Hz")
        print(f"  Duration: {self.duration:.1f} s")
        print(f"  Samples: {self.n_samples}")
        print(f"  Channel labels: {self.labels}")
        self._print_operation_history()

    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
        from ..processing import create_operation

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)

        # Apply processing to data
        processed_data = operation.process(self._data)

        # Update metadata
        operation_metadata = {"operation": operation_name, "params": params}
        new_history = self.operation_history.copy()
        new_history.append(operation_metadata)
        new_metadata = {**self.metadata}
        new_metadata[operation_name] = params

        # Get metadata updates from operation
        metadata_updates = operation.get_metadata_updates()

        # Update channel labels to reflect the operation
        display_name = operation.get_display_name()
        new_channel_metadata = self._relabel_channels(operation_name, display_name)

        logger.debug(
            f"Created new ChannelFrame with operation {operation_name} added to graph"
        )

        # Apply metadata updates (including sampling_rate if specified)
        creation_params: dict[str, Any] = {
            "data": processed_data,
            "metadata": new_metadata,
            "operation_history": new_history,
            "channel_metadata": new_channel_metadata,
        }
        creation_params.update(metadata_updates)

        return self._create_new_instance(**creation_params)

    def _binary_op(
        self,
        other: "ChannelFrame | int | float | NDArrayReal | DaskArray",
        op: Callable[["DaskArray", Any], "DaskArray"],
        symbol: str,
    ) -> "ChannelFrame":
        """
        Common implementation for binary operations
        - utilizing dask's lazy evaluation.

        Args:
            other: Right operand for the operation.
            op: Function to execute the operation (e.g., lambda a, b: a + b).
            symbol: Symbolic representation of the operation (e.g., '+').

        Returns:
            A new channel containing the operation result (lazy execution).
        """
        from .channel import ChannelFrame

        logger.debug(f"Setting up {symbol} operation (lazy)")

        # Handle potentially None metadata and operation_history
        metadata = {}
        if self.metadata is not None:
            metadata = self.metadata.copy()

        operation_history = []
        if self.operation_history is not None:
            operation_history = self.operation_history.copy()

        # Check if other is a ChannelFrame - improved type checking
        if isinstance(other, ChannelFrame):
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    "Sampling rates do not match. Cannot perform operation."
                )

            # Perform operation directly on dask array (maintaining lazy execution)
            result_data = op(self._data, other._data)

            # Merge channel metadata
            merged_channel_metadata = []
            for self_ch, other_ch in zip(
                self._channel_metadata, other._channel_metadata
            ):
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch['label']} {symbol} {other_ch['label']})"
                merged_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other.label})

            return ChannelFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                label=f"({self.label} {symbol} {other.label})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=merged_channel_metadata,
                previous=self,
            )

        # Perform operation with scalar, NumPy array, or other types
        else:
            # Apply operation directly on dask array (maintaining lazy execution)
            result_data = op(self._data, other)

            # Operand display string
            if isinstance(other, int | float):
                other_str = str(other)
            elif isinstance(other, np.ndarray):
                other_str = f"ndarray{other.shape}"
            elif hasattr(other, "shape"):  # Check for dask.array.Array
                other_str = f"dask.array{other.shape}"
            else:
                other_str = str(type(other).__name__)

            # Update channel metadata
            updated_channel_metadata: list[ChannelMetadata] = []
            for self_ch in self._channel_metadata:
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch.label} {symbol} {other_str})"
                updated_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other_str})

            return ChannelFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                label=f"({self.label} {symbol} {other_str})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=updated_channel_metadata,
                previous=self,
            )

    def add(
        self,
        other: "ChannelFrame | int | float | NDArrayReal",
        snr: float | None = None,
    ) -> "ChannelFrame":
        """Add another signal or value to the current signal.

        If SNR is specified, performs addition with consideration for
        signal-to-noise ratio.

        Args:
            other: Signal or value to add.
            snr: Signal-to-noise ratio (dB). If specified, adjusts the scale of the
                other signal based on this SNR.
                self is treated as the signal, and other as the noise.

        Returns:
            A new channel frame containing the addition result (lazy execution).
        """
        logger.debug(f"Setting up add operation with SNR={snr} (lazy)")

        if isinstance(other, ChannelFrame):
            # Check if sampling rates match
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    "Sampling rates do not match. Cannot perform operation."
                )

        elif isinstance(other, np.ndarray):
            other = ChannelFrame.from_numpy(
                other, self.sampling_rate, label="array_data"
            )
        elif isinstance(other, int | float):
            return self + other
        else:
            raise TypeError(
                "Addition target with SNR must be a ChannelFrame or "
                f"NumPy array: {type(other)}"
            )

        # If SNR is specified, adjust the length of the other signal
        if other.duration != self.duration:
            other = other.fix_length(length=self.n_samples)

        if snr is None:
            return self + other
        return self.apply_operation("add_with_snr", other=other._data, snr=snr)

    def plot(
        self,
        plot_type: str = "waveform",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        xlabel: str | None = None,
        ylabel: str | None = None,
        alpha: float = 1.0,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Plot the frame data.

        Args:
            plot_type: Type of plot. Default is "waveform".
            ax: Optional matplotlib axes for plotting.
            title: Title for the plot. If None, uses the frame label.
            overlay: Whether to overlay all channels on a single plot (True)
                or create separate subplots for each channel (False).
            xlabel: Label for the x-axis. If None, uses default based on plot type.
            ylabel: Label for the y-axis. If None, uses default based on plot type.
            alpha: Transparency level for the plot lines (0.0 to 1.0).
            xlim: Limits for the x-axis as (min, max) tuple.
            ylim: Limits for the y-axis as (min, max) tuple.
            **kwargs: Additional matplotlib Line2D parameters
                (e.g., color, linewidth, linestyle).
                These are passed to the underlying matplotlib plot functions.

        Returns:
            Single Axes object or iterator of Axes objects.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Basic plot
            >>> cf.plot()
            >>> # Overlay all channels
            >>> cf.plot(overlay=True, alpha=0.7)
            >>> # Custom styling
            >>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
        """
        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        from ..visualization.plotting import create_operation

        plot_strategy = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "overlay": overlay,
            **kwargs,
        }
        if xlabel is not None:
            plot_kwargs["xlabel"] = xlabel
        if ylabel is not None:
            plot_kwargs["ylabel"] = ylabel
        if alpha != 1.0:
            plot_kwargs["alpha"] = alpha
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def rms_plot(
        self,
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = True,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Generate an RMS plot.

        Args:
            ax: Optional matplotlib axes for plotting.
            title: Title for the plot.
            overlay: Whether to overlay the plot on the existing axis.
            Aw: Apply A-weighting.
            **kwargs: Additional arguments passed to the plot() method.
                Accepts the same arguments as plot() including xlabel, ylabel,
                alpha, xlim, ylim, and matplotlib Line2D parameters.

        Returns:
            Single Axes object or iterator of Axes objects.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Basic RMS plot
            >>> cf.rms_plot()
            >>> # With A-weighting
            >>> cf.rms_plot(Aw=True)
            >>> # Custom styling
            >>> cf.rms_plot(ylabel="RMS [V]", alpha=0.8, color="blue")
        """
        kwargs = kwargs or {}
        ylabel = kwargs.pop("ylabel", "RMS")
        rms_ch: ChannelFrame = self.rms_trend(Aw=Aw, dB=True)
        return rms_ch.plot(ax=ax, ylabel=ylabel, title=title, overlay=overlay, **kwargs)

    def describe(
        self,
        normalize: bool = True,
        is_close: bool = True,
        *,
        fmin: float = 0,
        fmax: float | None = None,
        cmap: str = "jet",
        vmin: float | None = None,
        vmax: float | None = None,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        waveform: dict[str, Any] | None = None,
        spectral: dict[str, Any] | None = None,
        **kwargs: Any,
    ) -> None:
        """Display visual and audio representation of the frame.

        This method creates a comprehensive visualization with three plots:
        1. Time-domain waveform (top)
        2. Spectrogram (bottom-left)
        3. Frequency spectrum via Welch method (bottom-right)

        Args:
            normalize: Whether to normalize the audio data for playback.
                Default: True
            is_close: Whether to close the figure after displaying.
                Default: True
            fmin: Minimum frequency to display in the spectrogram (Hz).
                Default: 0
            fmax: Maximum frequency to display in the spectrogram (Hz).
                Default: Nyquist frequency (sampling_rate / 2)
            cmap: Colormap for the spectrogram.
                Default: 'jet'
            vmin: Minimum value for spectrogram color scale (dB).
                Auto-calculated if None.
            vmax: Maximum value for spectrogram color scale (dB).
                Auto-calculated if None.
            xlim: Time axis limits (seconds) for all time-based plots.
                Format: (start_time, end_time)
            ylim: Frequency axis limits (Hz) for frequency-based plots.
                Format: (min_freq, max_freq)
            Aw: Apply A-weighting to the frequency analysis.
                Default: False
            waveform: Additional configuration dict for waveform subplot.
                Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
            spectral: Additional configuration dict for spectral subplot.
                Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
            **kwargs: Deprecated parameters for backward compatibility only.
                - axis_config: Old configuration format (use waveform/spectral instead)
                - cbar_config: Old colorbar configuration (use vmin/vmax instead)

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Basic usage
            >>> cf.describe()
            >>>
            >>> # Custom frequency range
            >>> cf.describe(fmin=100, fmax=5000)
            >>>
            >>> # Custom color scale
            >>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
            >>>
            >>> # A-weighted analysis
            >>> cf.describe(Aw=True)
            >>>
            >>> # Custom time range
            >>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
            >>>
            >>> # Custom waveform subplot settings
            >>> cf.describe(waveform={"ylabel": "Custom Label"})
        """
        # Prepare kwargs with explicit parameters
        plot_kwargs: dict[str, Any] = {
            "fmin": fmin,
            "fmax": fmax,
            "cmap": cmap,
            "vmin": vmin,
            "vmax": vmax,
            "xlim": xlim,
            "ylim": ylim,
            "Aw": Aw,
            "waveform": waveform or {},
            "spectral": spectral or {},
        }
        # Merge with additional kwargs
        plot_kwargs.update(kwargs)

        if "axis_config" in plot_kwargs:
            logger.warning(
                "axis_config is retained for backward compatibility but will "
                "be deprecated in the future."
            )
            axis_config = plot_kwargs["axis_config"]
            if "time_plot" in axis_config:
                plot_kwargs["waveform"] = axis_config["time_plot"]
            if "freq_plot" in axis_config:
                if "xlim" in axis_config["freq_plot"]:
                    vlim = axis_config["freq_plot"]["xlim"]
                    plot_kwargs["vmin"] = vlim[0]
                    plot_kwargs["vmax"] = vlim[1]
                if "ylim" in axis_config["freq_plot"]:
                    ylim_config = axis_config["freq_plot"]["ylim"]
                    plot_kwargs["ylim"] = ylim_config

        if "cbar_config" in plot_kwargs:
            logger.warning(
                "cbar_config is retained for backward compatibility but will "
                "be deprecated in the future."
            )
            cbar_config = plot_kwargs["cbar_config"]
            if "vmin" in cbar_config:
                plot_kwargs["vmin"] = cbar_config["vmin"]
            if "vmax" in cbar_config:
                plot_kwargs["vmax"] = cbar_config["vmax"]

        for ch in self:
            ax: Axes
            _ax = ch.plot("describe", title=f"{ch.label} {ch.labels[0]}", **plot_kwargs)
            if isinstance(_ax, Iterator):
                ax = next(iter(_ax))
            elif isinstance(_ax, Axes):
                ax = _ax
            else:
                raise TypeError(
                    f"Unexpected type for plot result: {type(_ax)}. Expected Axes or Iterator[Axes]."  # noqa: E501
                )
            # display関数とAudioクラスを使用
            display(ax.figure)
            if is_close:
                plt.close(getattr(ax, "figure", None))
            display(Audio(ch.data, rate=ch.sampling_rate, normalize=normalize))

    @classmethod
    def from_numpy(
        cls,
        data: NDArrayReal,
        sampling_rate: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        ch_labels: list[str] | None = None,
        ch_units: list[str] | str | None = None,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from a NumPy array.

        Args:
            data: NumPy array containing channel data.
            sampling_rate: The sampling rate in Hz.
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            ch_labels: Labels for each channel.
            ch_units: Units for each channel.

        Returns:
            A new ChannelFrame containing the NumPy data.
        """
        if data.ndim == 1:
            data = data.reshape(1, -1)
        elif data.ndim > 2:
            raise ValueError(
                f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}"
            )

        # Convert NumPy array to dask array
        dask_data = da_from_array(data)
        cf = cls(
            data=dask_data,
            sampling_rate=sampling_rate,
            label=label or "numpy_data",
        )
        if metadata is not None:
            cf.metadata = metadata
        if ch_labels is not None:
            if len(ch_labels) != cf.n_channels:
                raise ValueError(
                    "Number of channel labels does not match the number of channels"
                )
            for i in range(len(ch_labels)):
                cf._channel_metadata[i].label = ch_labels[i]
        if ch_units is not None:
            if isinstance(ch_units, str):
                ch_units = [ch_units] * cf.n_channels

            if len(ch_units) != cf.n_channels:
                raise ValueError(
                    "Number of channel units does not match the number of channels"
                )
            for i in range(len(ch_units)):
                cf._channel_metadata[i].unit = ch_units[i]

        return cf

    @classmethod
    def from_ndarray(
        cls,
        array: NDArrayReal,
        sampling_rate: float,
        labels: list[str] | None = None,
        unit: list[str] | str | None = None,
        frame_label: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from a NumPy array.

        This method is deprecated. Use from_numpy instead.

        Args:
            array: Signal data. Each row corresponds to a channel.
            sampling_rate: Sampling rate (Hz).
            labels: Labels for each channel.
            unit: Unit of the signal.
            frame_label: Label for the frame.
            metadata: Optional metadata dictionary.

        Returns:
            A new ChannelFrame containing the data.
        """
        # Redirect to from_numpy for compatibility
        # However, from_ndarray is deprecated
        logger.warning("from_ndarray is deprecated. Use from_numpy instead.")
        return cls.from_numpy(
            data=array,
            sampling_rate=sampling_rate,
            label=frame_label,
            metadata=metadata,
            ch_labels=labels,
            ch_units=unit,
        )

    @classmethod
    def from_file(
        cls,
        path: str | Path,
        channel: int | list[int] | None = None,
        start: float | None = None,
        end: float | None = None,
        chunk_size: int | None = None,
        ch_labels: list[str] | None = None,
        # CSV-specific parameters
        time_column: int | str = 0,
        delimiter: str = ",",
        header: int | None = 0,
    ) -> "ChannelFrame":
        """Create a ChannelFrame from an audio file.

        Args:
            path: Path to the audio file.
            channel: Channel(s) to load.
            start: Start time in seconds.
            end: End time in seconds.
            chunk_size: Chunk size for processing.
                Specifies the splitting size for lazy processing.
            ch_labels: Labels for each channel.
            time_column: For CSV files, index or name of the time column.
                Default is 0 (first column).
            delimiter: For CSV files, delimiter character. Default is ",".
            header: For CSV files, row number to use as header.
                Default is 0 (first row). Set to None if no header.

        Returns:
            A new ChannelFrame containing the loaded audio data.

        Raises:
            ValueError: If channel specification is invalid.
            TypeError: If channel parameter type is invalid.
            FileNotFoundError: If the file doesn't exist at the specified path.
                Error message includes absolute path, current directory, and
                troubleshooting suggestions.

        Examples:
            >>> # Load WAV file
            >>> cf = ChannelFrame.from_file("audio.wav")
            >>> # Load specific channels
            >>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
            >>> # Load CSV file
            >>> cf = ChannelFrame.from_file(
            ...     "data.csv", time_column=0, delimiter=",", header=0
            ... )
        """
        from .channel import ChannelFrame

        path = Path(path)
        if not path.exists():
            raise FileNotFoundError(
                f"Audio file not found\n"
                f"  Path: {path.absolute()}\n"
                f"  Current directory: {Path.cwd()}\n"
                f"Please check:\n"
                f"  - File path is correct\n"
                f"  - File exists at the specified location\n"
                f"  - You have read permissions for the file"
            )

        # Get file reader
        reader = get_file_reader(path)

        # Build kwargs for reader
        reader_kwargs: dict[str, Any] = {}
        if path.suffix.lower() == ".csv":
            reader_kwargs["time_column"] = time_column
            reader_kwargs["delimiter"] = delimiter
            if header is not None:
                reader_kwargs["header"] = header

        # Get file info
        info = reader.get_file_info(path, **reader_kwargs)
        sr = info["samplerate"]
        n_channels = info["channels"]
        n_frames = info["frames"]
        ch_labels = ch_labels or info.get("ch_labels", None)

        logger.debug(f"File info: sr={sr}, channels={n_channels}, frames={n_frames}")

        # Channel selection processing
        all_channels = list(range(n_channels))

        if channel is None:
            channels_to_load = all_channels
            logger.debug(f"Will load all channels: {channels_to_load}")
        elif isinstance(channel, int):
            if channel < 0 or channel >= n_channels:
                raise ValueError(
                    f"Channel specification is out of range: {channel} (valid range: 0-{n_channels - 1})"  # noqa: E501
                )
            channels_to_load = [channel]
            logger.debug(f"Will load single channel: {channel}")
        elif isinstance(channel, list | tuple):
            for ch in channel:
                if ch < 0 or ch >= n_channels:
                    raise ValueError(
                        f"Channel specification is out of range: {ch} (valid range: 0-{n_channels - 1})"  # noqa: E501
                    )
            channels_to_load = list(channel)
            logger.debug(f"Will load specific channels: {channels_to_load}")
        else:
            raise TypeError("channel must be int, list, or None")

        # Index calculation
        start_idx = 0 if start is None else max(0, int(start * sr))
        end_idx = n_frames if end is None else min(n_frames, int(end * sr))
        frames_to_read = end_idx - start_idx

        logger.debug(
            f"Setting up lazy load from file={path}, frames={frames_to_read}, "
            f"start_idx={start_idx}, end_idx={end_idx}"
        )

        # Settings for lazy loading
        expected_shape = (len(channels_to_load), frames_to_read)

        # Define the loading function using the file reader
        def _load_audio() -> NDArrayReal:
            logger.debug(">>> EXECUTING DELAYED LOAD <<<")
            # Use the reader to get audio data with parameters
            out = reader.get_data(
                path, channels_to_load, start_idx, frames_to_read, **reader_kwargs
            )
            if not isinstance(out, np.ndarray):
                raise ValueError("Unexpected data type after reading file")
            return out

        logger.debug(
            f"Creating delayed dask task with expected shape: {expected_shape}"
        )

        # Create delayed operation
        delayed_data = dask_delayed(_load_audio)()
        logger.debug("Wrapping delayed function in dask array")

        # Create dask array from delayed computation
        dask_array = da_from_delayed(
            delayed_data, shape=expected_shape, dtype=np.float32
        )

        if chunk_size is not None:
            if chunk_size <= 0:
                raise ValueError("Chunk size must be a positive integer")
            logger.debug(f"Setting chunk size: {chunk_size} for sample axis")
            dask_array = dask_array.rechunk({0: -1, 1: chunk_size})

        logger.debug(
            "ChannelFrame setup complete - actual file reading will occur on compute()"  # noqa: E501
        )

        cf = ChannelFrame(
            data=dask_array,
            sampling_rate=sr,
            label=path.stem,
            metadata={
                "filename": str(path),
            },
        )
        if ch_labels is not None:
            if len(ch_labels) != len(cf):
                raise ValueError(
                    "Number of channel labels does not match the number of specified channels"  # noqa: E501
                )
            for i in range(len(ch_labels)):
                cf._channel_metadata[i].label = ch_labels[i]
        return cf

    @classmethod
    def read_wav(cls, filename: str, labels: list[str] | None = None) -> "ChannelFrame":
        """Utility method to read a WAV file.

        Args:
            filename: Path to the WAV file.
            labels: Labels to set for each channel.

        Returns:
            A new ChannelFrame containing the data (lazy loading).
        """
        from .channel import ChannelFrame

        cf = ChannelFrame.from_file(filename, ch_labels=labels)
        return cf

    @classmethod
    def read_csv(
        cls,
        filename: str,
        time_column: int | str = 0,
        labels: list[str] | None = None,
        delimiter: str = ",",
        header: int | None = 0,
    ) -> "ChannelFrame":
        """Utility method to read a CSV file.

        Args:
            filename: Path to the CSV file.
            time_column: Index or name of the time column.
            labels: Labels to set for each channel.
            delimiter: Delimiter character.
            header: Row number to use as header.

        Returns:
            A new ChannelFrame containing the data (lazy loading).

        Examples:
            >>> # Read CSV with default settings
            >>> cf = ChannelFrame.read_csv("data.csv")
            >>> # Read CSV with custom delimiter
            >>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
            >>> # Read CSV without header
            >>> cf = ChannelFrame.read_csv("data.csv", header=None)
        """
        from .channel import ChannelFrame

        cf = ChannelFrame.from_file(
            filename,
            ch_labels=labels,
            time_column=time_column,
            delimiter=delimiter,
            header=header,
        )
        return cf

    def to_wav(self, path: str | Path, format: str | None = None) -> None:
        """Save the audio data to a WAV file.

        Args:
            path: Path to save the file.
            format: File format. If None, determined from file extension.
        """
        from wandas.io.wav_io import write_wav

        write_wav(str(path), self, format=format)

    def save(
        self,
        path: str | Path,
        *,
        format: str = "hdf5",
        compress: str | None = "gzip",
        overwrite: bool = False,
        dtype: str | np.dtype[Any] | None = None,
    ) -> None:
        """Save the ChannelFrame to a WDF (Wandas Data File) format.

        This saves the complete frame including all channel data and metadata
        in a format that can be loaded back with full fidelity.

        Args:
            path: Path to save the file. '.wdf' extension will be added if not present.
            format: Format to use (currently only 'hdf5' is supported)
            compress: Compression method ('gzip' by default, None for no compression)
            overwrite: Whether to overwrite existing file
            dtype: Optional data type conversion before saving (e.g. 'float32')

        Raises:
            FileExistsError: If the file exists and overwrite=False.
            NotImplementedError: For unsupported formats.

        Example:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> cf.save("audio_analysis.wdf")
        """
        from ..io.wdf_io import save as wdf_save

        wdf_save(
            self,
            path,
            format=format,
            compress=compress,
            overwrite=overwrite,
            dtype=dtype,
        )

    @classmethod
    def load(cls, path: str | Path, *, format: str = "hdf5") -> "ChannelFrame":
        """Load a ChannelFrame from a WDF (Wandas Data File) file.

        This loads data saved with the save() method, preserving all channel data,
        metadata, labels, and units.

        Args:
            path: Path to the WDF file
            format: Format of the file (currently only 'hdf5' is supported)

        Returns:
            A new ChannelFrame with all data and metadata loaded

        Raises:
            FileNotFoundError: If the file doesn't exist
            NotImplementedError: For unsupported formats

        Example:
            >>> cf = ChannelFrame.load("audio_analysis.wdf")
        """
        from ..io.wdf_io import load as wdf_load

        return wdf_load(path, format=format)

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """Provide additional initialization arguments required for ChannelFrame."""
        return {}

    def add_channel(
        self,
        data: "np.ndarray[Any, Any] | DaskArray | ChannelFrame",
        label: str | None = None,
        align: str = "strict",
        suffix_on_dup: str | None = None,
        inplace: bool = False,
    ) -> "ChannelFrame":
        """Add a new channel to the frame.

        Args:
            data: Data to add as a new channel. Can be:
                - numpy array (1D or 2D)
                - dask array (1D or 2D)
                - ChannelFrame (channels will be added)
            label: Label for the new channel. If None, generates a default label.
                Ignored when data is a ChannelFrame (uses its channel labels).
            align: How to handle length mismatches:
                - "strict": Raise error if lengths don't match
                - "pad": Pad shorter data with zeros
                - "truncate": Truncate longer data to match
            suffix_on_dup: Suffix to add to duplicate labels. If None, raises error.
            inplace: If True, modifies the frame in place.
                Otherwise returns a new frame.

        Returns:
            Modified ChannelFrame (self if inplace=True, new frame otherwise).

        Raises:
            ValueError: If data length doesn't match and align="strict",
                or if label is duplicate and suffix_on_dup is None.
            TypeError: If data type is not supported.

        Examples:
            >>> cf = ChannelFrame.read_wav("audio.wav")
            >>> # Add a numpy array as a new channel
            >>> new_data = np.sin(2 * np.pi * 440 * cf.time)
            >>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
            >>> # Add another ChannelFrame's channels
            >>> cf2 = ChannelFrame.read_wav("audio2.wav")
            >>> cf_combined = cf.add_channel(cf2)
        """
        # ndarray/dask/同型Frame対応
        if isinstance(data, ChannelFrame):
            if self.sampling_rate != data.sampling_rate:
                raise ValueError("sampling_rate不一致")
            if data.n_samples != self.n_samples:
                if align == "pad":
                    pad_len = self.n_samples - data.n_samples
                    arr = data._data
                    if pad_len > 0:
                        arr = concatenate(
                            [
                                arr,
                                from_array(
                                    np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                                ),
                            ],
                            axis=1,
                        )
                    else:
                        arr = arr[:, : self.n_samples]
                elif align == "truncate":
                    arr = data._data[:, : self.n_samples]
                    if arr.shape[1] < self.n_samples:
                        pad_len = self.n_samples - arr.shape[1]
                        arr = concatenate(
                            [
                                arr,
                                from_array(
                                    np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                                ),
                            ],
                            axis=1,
                        )
                else:
                    raise ValueError("データ長不一致: align指定を確認")
            else:
                arr = data._data
            labels = [ch.label for ch in self._channel_metadata]
            new_labels = []
            new_metadata_list = []
            for chmeta in data._channel_metadata:
                new_label = chmeta.label
                if new_label in labels or new_label in new_labels:
                    if suffix_on_dup:
                        new_label += suffix_on_dup
                    else:
                        raise ValueError(f"label重複: {new_label}")
                new_labels.append(new_label)
                # Copy the entire channel_metadata and update only the label
                new_ch_meta = chmeta.model_copy(deep=True)
                new_ch_meta.label = new_label
                new_metadata_list.append(new_ch_meta)
            new_data = concatenate([self._data, arr], axis=0)

            new_chmeta = self._channel_metadata + new_metadata_list
            if inplace:
                self._data = new_data
                self._channel_metadata = new_chmeta
                return self
            else:
                return ChannelFrame(
                    data=new_data,
                    sampling_rate=self.sampling_rate,
                    label=self.label,
                    metadata=self.metadata,
                    operation_history=self.operation_history,
                    channel_metadata=new_chmeta,
                    previous=self,
                )
        if isinstance(data, np.ndarray):
            arr = from_array(data.reshape(1, -1))
        elif isinstance(data, DaskArray):
            arr = data[None, ...] if data.ndim == 1 else data
            if arr.shape[0] != 1:
                arr = arr.reshape((1, -1))
        else:
            raise TypeError("add_channel: ndarray/dask/同型Frameのみ対応")
        if arr.shape[1] != self.n_samples:
            if align == "pad":
                pad_len = self.n_samples - arr.shape[1]
                if pad_len > 0:
                    arr = concatenate(
                        [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                        axis=1,
                    )
                else:
                    arr = arr[:, : self.n_samples]
            elif align == "truncate":
                arr = arr[:, : self.n_samples]
                if arr.shape[1] < self.n_samples:
                    pad_len = self.n_samples - arr.shape[1]
                    arr = concatenate(
                        [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                        axis=1,
                    )
            else:
                raise ValueError("データ長不一致: align指定を確認")
        labels = [ch.label for ch in self._channel_metadata]
        new_label = label or f"ch{len(labels)}"
        if new_label in labels:
            if suffix_on_dup:
                new_label += suffix_on_dup
            else:
                raise ValueError("label重複")
        new_data = concatenate([self._data, arr], axis=0)
        from ..core.metadata import ChannelMetadata

        new_chmeta = self._channel_metadata + [ChannelMetadata(label=new_label)]
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )

    def remove_channel(self, key: int | str, inplace: bool = False) -> "ChannelFrame":
        if isinstance(key, int):
            if not (0 <= key < self.n_channels):
                raise IndexError(f"index {key} out of range")
            idx = key
        else:
            labels = [ch.label for ch in self._channel_metadata]
            if key not in labels:
                raise KeyError(f"label {key} not found")
            idx = labels.index(key)
        new_data = self._data[[i for i in range(self.n_channels) if i != idx], :]
        new_chmeta = [ch for i, ch in enumerate(self._channel_metadata) if i != idx]
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get time index for DataFrame."""
        return pd.Index(self.time, name="time")
Attributes
time property

Get time array for the signal.

The time array represents the start time of each sample, calculated as sample_index / sampling_rate. This provides a uniform, evenly-spaced time axis that is consistent across all frame types in wandas.

For frames resulting from windowed analysis operations (e.g., FFT, loudness, roughness), each time point corresponds to the start of the analysis window, not the center. This differs from some libraries (e.g., MoSQITo) which use window center times, but does not affect the calculated values themselves.

Returns:

Type Description
NDArrayReal

Array of time points in seconds, starting from 0.0.

Examples:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> time = signal.time
>>> print(f"Duration: {time[-1]:.3f}s")
>>> print(f"Time step: {time[1] - time[0]:.6f}s")
n_samples property

Returns the number of samples.

duration property

Returns the duration in seconds.

rms property

Calculate RMS (Root Mean Square) value for each channel.

Returns:

Type Description
NDArrayReal

Array of RMS values, one per channel.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> rms_values = cf.rms
>>> print(f"RMS values: {rms_values}")
>>> # Select channels with RMS > threshold
>>> active_channels = cf[cf.rms > 0.5]
Functions
__init__(data, sampling_rate, label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)

Initialize a ChannelFrame.

Parameters:

Name Type Description Default
data Array

Dask array containing channel data.

required
sampling_rate float

The sampling rate of the data in Hz. Must be a positive value.

required
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
operation_history list[dict[str, Any]] | None

History of operations applied to the frame.

None
channel_metadata list[ChannelMetadata] | list[dict[str, Any]] | None

Metadata for each channel.

None
previous Optional[BaseFrame[Any]]

Reference to the previous frame in the processing chain.

None

Raises:

Type Description
ValueError

If data has more than 2 dimensions, or if sampling_rate is not positive.

Source code in wandas/frames/channel.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def __init__(
    self,
    data: DaskArray,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> None:
    """Initialize a ChannelFrame.

    Args:
        data: Dask array containing channel data.
        Shape should be (n_channels, n_samples).
        sampling_rate: The sampling rate of the data in Hz.
            Must be a positive value.
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        operation_history: History of operations applied to the frame.
        channel_metadata: Metadata for each channel.
        previous: Reference to the previous frame in the processing chain.

    Raises:
        ValueError: If data has more than 2 dimensions, or if
            sampling_rate is not positive.
    """
    # Validate sampling rate
    validate_sampling_rate(sampling_rate)

    # Validate and reshape data
    if data.ndim == 1:
        data = da.reshape(data, (1, -1))
    elif data.ndim > 2:
        raise ValueError(
            f"Invalid data shape for ChannelFrame\n"
            f"  Got: {data.shape} ({data.ndim}D)\n"
            f"  Expected: 1D (samples,) or 2D (channels, samples)\n"
            f"If you have a 1D array, it will be automatically reshaped to\n"
            f"  (1, n_samples).\n"
            f"For higher-dimensional data, reshape it before creating\n"
            f"  ChannelFrame:\n"
            f"  Example: data.reshape(n_channels, -1)"
        )
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
info()

Display comprehensive information about the ChannelFrame.

This method prints a summary of the frame's properties including: - Number of channels - Sampling rate - Duration - Number of samples - Channel labels

This is a convenience method to view all key properties at once, similar to pandas DataFrame.info().

Examples

cf = ChannelFrame.read_wav("audio.wav") cf.info() Channels: 2 Sampling rate: 44100 Hz Duration: 1.0 s Samples: 44100 Channel labels: ['ch0', 'ch1']

Source code in wandas/frames/channel.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def info(self) -> None:
    """Display comprehensive information about the ChannelFrame.

    This method prints a summary of the frame's properties including:
    - Number of channels
    - Sampling rate
    - Duration
    - Number of samples
    - Channel labels

    This is a convenience method to view all key properties at once,
    similar to pandas DataFrame.info().

    Examples
    --------
    >>> cf = ChannelFrame.read_wav("audio.wav")
    >>> cf.info()
    Channels: 2
    Sampling rate: 44100 Hz
    Duration: 1.0 s
    Samples: 44100
    Channel labels: ['ch0', 'ch1']
    """
    print("ChannelFrame Information:")
    print(f"  Channels: {self.n_channels}")
    print(f"  Sampling rate: {self.sampling_rate} Hz")
    print(f"  Duration: {self.duration:.1f} s")
    print(f"  Samples: {self.n_samples}")
    print(f"  Channel labels: {self.labels}")
    self._print_operation_history()
add(other, snr=None)

Add another signal or value to the current signal.

If SNR is specified, performs addition with consideration for signal-to-noise ratio.

Parameters:

Name Type Description Default
other ChannelFrame | int | float | NDArrayReal

Signal or value to add.

required
snr float | None

Signal-to-noise ratio (dB). If specified, adjusts the scale of the other signal based on this SNR. self is treated as the signal, and other as the noise.

None

Returns:

Type Description
ChannelFrame

A new channel frame containing the addition result (lazy execution).

Source code in wandas/frames/channel.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
def add(
    self,
    other: "ChannelFrame | int | float | NDArrayReal",
    snr: float | None = None,
) -> "ChannelFrame":
    """Add another signal or value to the current signal.

    If SNR is specified, performs addition with consideration for
    signal-to-noise ratio.

    Args:
        other: Signal or value to add.
        snr: Signal-to-noise ratio (dB). If specified, adjusts the scale of the
            other signal based on this SNR.
            self is treated as the signal, and other as the noise.

    Returns:
        A new channel frame containing the addition result (lazy execution).
    """
    logger.debug(f"Setting up add operation with SNR={snr} (lazy)")

    if isinstance(other, ChannelFrame):
        # Check if sampling rates match
        if self.sampling_rate != other.sampling_rate:
            raise ValueError(
                "Sampling rates do not match. Cannot perform operation."
            )

    elif isinstance(other, np.ndarray):
        other = ChannelFrame.from_numpy(
            other, self.sampling_rate, label="array_data"
        )
    elif isinstance(other, int | float):
        return self + other
    else:
        raise TypeError(
            "Addition target with SNR must be a ChannelFrame or "
            f"NumPy array: {type(other)}"
        )

    # If SNR is specified, adjust the length of the other signal
    if other.duration != self.duration:
        other = other.fix_length(length=self.n_samples)

    if snr is None:
        return self + other
    return self.apply_operation("add_with_snr", other=other._data, snr=snr)
plot(plot_type='waveform', ax=None, title=None, overlay=False, xlabel=None, ylabel=None, alpha=1.0, xlim=None, ylim=None, **kwargs)

Plot the frame data.

Parameters:

Name Type Description Default
plot_type str

Type of plot. Default is "waveform".

'waveform'
ax Optional[Axes]

Optional matplotlib axes for plotting.

None
title str | None

Title for the plot. If None, uses the frame label.

None
overlay bool

Whether to overlay all channels on a single plot (True) or create separate subplots for each channel (False).

False
xlabel str | None

Label for the x-axis. If None, uses default based on plot type.

None
ylabel str | None

Label for the y-axis. If None, uses default based on plot type.

None
alpha float

Transparency level for the plot lines (0.0 to 1.0).

1.0
xlim tuple[float, float] | None

Limits for the x-axis as (min, max) tuple.

None
ylim tuple[float, float] | None

Limits for the y-axis as (min, max) tuple.

None
**kwargs Any

Additional matplotlib Line2D parameters (e.g., color, linewidth, linestyle). These are passed to the underlying matplotlib plot functions.

{}

Returns:

Type Description
Axes | Iterator[Axes]

Single Axes object or iterator of Axes objects.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Basic plot
>>> cf.plot()
>>> # Overlay all channels
>>> cf.plot(overlay=True, alpha=0.7)
>>> # Custom styling
>>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
Source code in wandas/frames/channel.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
def plot(
    self,
    plot_type: str = "waveform",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    xlabel: str | None = None,
    ylabel: str | None = None,
    alpha: float = 1.0,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Plot the frame data.

    Args:
        plot_type: Type of plot. Default is "waveform".
        ax: Optional matplotlib axes for plotting.
        title: Title for the plot. If None, uses the frame label.
        overlay: Whether to overlay all channels on a single plot (True)
            or create separate subplots for each channel (False).
        xlabel: Label for the x-axis. If None, uses default based on plot type.
        ylabel: Label for the y-axis. If None, uses default based on plot type.
        alpha: Transparency level for the plot lines (0.0 to 1.0).
        xlim: Limits for the x-axis as (min, max) tuple.
        ylim: Limits for the y-axis as (min, max) tuple.
        **kwargs: Additional matplotlib Line2D parameters
            (e.g., color, linewidth, linestyle).
            These are passed to the underlying matplotlib plot functions.

    Returns:
        Single Axes object or iterator of Axes objects.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Basic plot
        >>> cf.plot()
        >>> # Overlay all channels
        >>> cf.plot(overlay=True, alpha=0.7)
        >>> # Custom styling
        >>> cf.plot(title="My Signal", ylabel="Voltage [V]", color="red")
    """
    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    from ..visualization.plotting import create_operation

    plot_strategy = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "overlay": overlay,
        **kwargs,
    }
    if xlabel is not None:
        plot_kwargs["xlabel"] = xlabel
    if ylabel is not None:
        plot_kwargs["ylabel"] = ylabel
    if alpha != 1.0:
        plot_kwargs["alpha"] = alpha
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax
rms_plot(ax=None, title=None, overlay=True, Aw=False, **kwargs)

Generate an RMS plot.

Parameters:

Name Type Description Default
ax Optional[Axes]

Optional matplotlib axes for plotting.

None
title str | None

Title for the plot.

None
overlay bool

Whether to overlay the plot on the existing axis.

True
Aw bool

Apply A-weighting.

False
**kwargs Any

Additional arguments passed to the plot() method. Accepts the same arguments as plot() including xlabel, ylabel, alpha, xlim, ylim, and matplotlib Line2D parameters.

{}

Returns:

Type Description
Axes | Iterator[Axes]

Single Axes object or iterator of Axes objects.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Basic RMS plot
>>> cf.rms_plot()
>>> # With A-weighting
>>> cf.rms_plot(Aw=True)
>>> # Custom styling
>>> cf.rms_plot(ylabel="RMS [V]", alpha=0.8, color="blue")
Source code in wandas/frames/channel.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
def rms_plot(
    self,
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = True,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Generate an RMS plot.

    Args:
        ax: Optional matplotlib axes for plotting.
        title: Title for the plot.
        overlay: Whether to overlay the plot on the existing axis.
        Aw: Apply A-weighting.
        **kwargs: Additional arguments passed to the plot() method.
            Accepts the same arguments as plot() including xlabel, ylabel,
            alpha, xlim, ylim, and matplotlib Line2D parameters.

    Returns:
        Single Axes object or iterator of Axes objects.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Basic RMS plot
        >>> cf.rms_plot()
        >>> # With A-weighting
        >>> cf.rms_plot(Aw=True)
        >>> # Custom styling
        >>> cf.rms_plot(ylabel="RMS [V]", alpha=0.8, color="blue")
    """
    kwargs = kwargs or {}
    ylabel = kwargs.pop("ylabel", "RMS")
    rms_ch: ChannelFrame = self.rms_trend(Aw=Aw, dB=True)
    return rms_ch.plot(ax=ax, ylabel=ylabel, title=title, overlay=overlay, **kwargs)
describe(normalize=True, is_close=True, *, fmin=0, fmax=None, cmap='jet', vmin=None, vmax=None, xlim=None, ylim=None, Aw=False, waveform=None, spectral=None, **kwargs)

Display visual and audio representation of the frame.

This method creates a comprehensive visualization with three plots: 1. Time-domain waveform (top) 2. Spectrogram (bottom-left) 3. Frequency spectrum via Welch method (bottom-right)

Parameters:

Name Type Description Default
normalize bool

Whether to normalize the audio data for playback. Default: True

True
is_close bool

Whether to close the figure after displaying. Default: True

True
fmin float

Minimum frequency to display in the spectrogram (Hz). Default: 0

0
fmax float | None

Maximum frequency to display in the spectrogram (Hz). Default: Nyquist frequency (sampling_rate / 2)

None
cmap str

Colormap for the spectrogram. Default: 'jet'

'jet'
vmin float | None

Minimum value for spectrogram color scale (dB). Auto-calculated if None.

None
vmax float | None

Maximum value for spectrogram color scale (dB). Auto-calculated if None.

None
xlim tuple[float, float] | None

Time axis limits (seconds) for all time-based plots. Format: (start_time, end_time)

None
ylim tuple[float, float] | None

Frequency axis limits (Hz) for frequency-based plots. Format: (min_freq, max_freq)

None
Aw bool

Apply A-weighting to the frequency analysis. Default: False

False
waveform dict[str, Any] | None

Additional configuration dict for waveform subplot. Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.

None
spectral dict[str, Any] | None

Additional configuration dict for spectral subplot. Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.

None
**kwargs Any

Deprecated parameters for backward compatibility only. - axis_config: Old configuration format (use waveform/spectral instead) - cbar_config: Old colorbar configuration (use vmin/vmax instead)

{}

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Basic usage
>>> cf.describe()
>>>
>>> # Custom frequency range
>>> cf.describe(fmin=100, fmax=5000)
>>>
>>> # Custom color scale
>>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
>>>
>>> # A-weighted analysis
>>> cf.describe(Aw=True)
>>>
>>> # Custom time range
>>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
>>>
>>> # Custom waveform subplot settings
>>> cf.describe(waveform={"ylabel": "Custom Label"})
Source code in wandas/frames/channel.py
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
def describe(
    self,
    normalize: bool = True,
    is_close: bool = True,
    *,
    fmin: float = 0,
    fmax: float | None = None,
    cmap: str = "jet",
    vmin: float | None = None,
    vmax: float | None = None,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    waveform: dict[str, Any] | None = None,
    spectral: dict[str, Any] | None = None,
    **kwargs: Any,
) -> None:
    """Display visual and audio representation of the frame.

    This method creates a comprehensive visualization with three plots:
    1. Time-domain waveform (top)
    2. Spectrogram (bottom-left)
    3. Frequency spectrum via Welch method (bottom-right)

    Args:
        normalize: Whether to normalize the audio data for playback.
            Default: True
        is_close: Whether to close the figure after displaying.
            Default: True
        fmin: Minimum frequency to display in the spectrogram (Hz).
            Default: 0
        fmax: Maximum frequency to display in the spectrogram (Hz).
            Default: Nyquist frequency (sampling_rate / 2)
        cmap: Colormap for the spectrogram.
            Default: 'jet'
        vmin: Minimum value for spectrogram color scale (dB).
            Auto-calculated if None.
        vmax: Maximum value for spectrogram color scale (dB).
            Auto-calculated if None.
        xlim: Time axis limits (seconds) for all time-based plots.
            Format: (start_time, end_time)
        ylim: Frequency axis limits (Hz) for frequency-based plots.
            Format: (min_freq, max_freq)
        Aw: Apply A-weighting to the frequency analysis.
            Default: False
        waveform: Additional configuration dict for waveform subplot.
            Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
        spectral: Additional configuration dict for spectral subplot.
            Can include 'xlabel', 'ylabel', 'xlim', 'ylim'.
        **kwargs: Deprecated parameters for backward compatibility only.
            - axis_config: Old configuration format (use waveform/spectral instead)
            - cbar_config: Old colorbar configuration (use vmin/vmax instead)

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Basic usage
        >>> cf.describe()
        >>>
        >>> # Custom frequency range
        >>> cf.describe(fmin=100, fmax=5000)
        >>>
        >>> # Custom color scale
        >>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
        >>>
        >>> # A-weighted analysis
        >>> cf.describe(Aw=True)
        >>>
        >>> # Custom time range
        >>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
        >>>
        >>> # Custom waveform subplot settings
        >>> cf.describe(waveform={"ylabel": "Custom Label"})
    """
    # Prepare kwargs with explicit parameters
    plot_kwargs: dict[str, Any] = {
        "fmin": fmin,
        "fmax": fmax,
        "cmap": cmap,
        "vmin": vmin,
        "vmax": vmax,
        "xlim": xlim,
        "ylim": ylim,
        "Aw": Aw,
        "waveform": waveform or {},
        "spectral": spectral or {},
    }
    # Merge with additional kwargs
    plot_kwargs.update(kwargs)

    if "axis_config" in plot_kwargs:
        logger.warning(
            "axis_config is retained for backward compatibility but will "
            "be deprecated in the future."
        )
        axis_config = plot_kwargs["axis_config"]
        if "time_plot" in axis_config:
            plot_kwargs["waveform"] = axis_config["time_plot"]
        if "freq_plot" in axis_config:
            if "xlim" in axis_config["freq_plot"]:
                vlim = axis_config["freq_plot"]["xlim"]
                plot_kwargs["vmin"] = vlim[0]
                plot_kwargs["vmax"] = vlim[1]
            if "ylim" in axis_config["freq_plot"]:
                ylim_config = axis_config["freq_plot"]["ylim"]
                plot_kwargs["ylim"] = ylim_config

    if "cbar_config" in plot_kwargs:
        logger.warning(
            "cbar_config is retained for backward compatibility but will "
            "be deprecated in the future."
        )
        cbar_config = plot_kwargs["cbar_config"]
        if "vmin" in cbar_config:
            plot_kwargs["vmin"] = cbar_config["vmin"]
        if "vmax" in cbar_config:
            plot_kwargs["vmax"] = cbar_config["vmax"]

    for ch in self:
        ax: Axes
        _ax = ch.plot("describe", title=f"{ch.label} {ch.labels[0]}", **plot_kwargs)
        if isinstance(_ax, Iterator):
            ax = next(iter(_ax))
        elif isinstance(_ax, Axes):
            ax = _ax
        else:
            raise TypeError(
                f"Unexpected type for plot result: {type(_ax)}. Expected Axes or Iterator[Axes]."  # noqa: E501
            )
        # display関数とAudioクラスを使用
        display(ax.figure)
        if is_close:
            plt.close(getattr(ax, "figure", None))
        display(Audio(ch.data, rate=ch.sampling_rate, normalize=normalize))
from_numpy(data, sampling_rate, label=None, metadata=None, ch_labels=None, ch_units=None) classmethod

Create a ChannelFrame from a NumPy array.

Parameters:

Name Type Description Default
data NDArrayReal

NumPy array containing channel data.

required
sampling_rate float

The sampling rate in Hz.

required
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
ch_labels list[str] | None

Labels for each channel.

None
ch_units list[str] | str | None

Units for each channel.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the NumPy data.

Source code in wandas/frames/channel.py
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
@classmethod
def from_numpy(
    cls,
    data: NDArrayReal,
    sampling_rate: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    ch_labels: list[str] | None = None,
    ch_units: list[str] | str | None = None,
) -> "ChannelFrame":
    """Create a ChannelFrame from a NumPy array.

    Args:
        data: NumPy array containing channel data.
        sampling_rate: The sampling rate in Hz.
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        ch_labels: Labels for each channel.
        ch_units: Units for each channel.

    Returns:
        A new ChannelFrame containing the NumPy data.
    """
    if data.ndim == 1:
        data = data.reshape(1, -1)
    elif data.ndim > 2:
        raise ValueError(
            f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}"
        )

    # Convert NumPy array to dask array
    dask_data = da_from_array(data)
    cf = cls(
        data=dask_data,
        sampling_rate=sampling_rate,
        label=label or "numpy_data",
    )
    if metadata is not None:
        cf.metadata = metadata
    if ch_labels is not None:
        if len(ch_labels) != cf.n_channels:
            raise ValueError(
                "Number of channel labels does not match the number of channels"
            )
        for i in range(len(ch_labels)):
            cf._channel_metadata[i].label = ch_labels[i]
    if ch_units is not None:
        if isinstance(ch_units, str):
            ch_units = [ch_units] * cf.n_channels

        if len(ch_units) != cf.n_channels:
            raise ValueError(
                "Number of channel units does not match the number of channels"
            )
        for i in range(len(ch_units)):
            cf._channel_metadata[i].unit = ch_units[i]

    return cf
from_ndarray(array, sampling_rate, labels=None, unit=None, frame_label=None, metadata=None) classmethod

Create a ChannelFrame from a NumPy array.

This method is deprecated. Use from_numpy instead.

Parameters:

Name Type Description Default
array NDArrayReal

Signal data. Each row corresponds to a channel.

required
sampling_rate float

Sampling rate (Hz).

required
labels list[str] | None

Labels for each channel.

None
unit list[str] | str | None

Unit of the signal.

None
frame_label str | None

Label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data.

Source code in wandas/frames/channel.py
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
@classmethod
def from_ndarray(
    cls,
    array: NDArrayReal,
    sampling_rate: float,
    labels: list[str] | None = None,
    unit: list[str] | str | None = None,
    frame_label: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> "ChannelFrame":
    """Create a ChannelFrame from a NumPy array.

    This method is deprecated. Use from_numpy instead.

    Args:
        array: Signal data. Each row corresponds to a channel.
        sampling_rate: Sampling rate (Hz).
        labels: Labels for each channel.
        unit: Unit of the signal.
        frame_label: Label for the frame.
        metadata: Optional metadata dictionary.

    Returns:
        A new ChannelFrame containing the data.
    """
    # Redirect to from_numpy for compatibility
    # However, from_ndarray is deprecated
    logger.warning("from_ndarray is deprecated. Use from_numpy instead.")
    return cls.from_numpy(
        data=array,
        sampling_rate=sampling_rate,
        label=frame_label,
        metadata=metadata,
        ch_labels=labels,
        ch_units=unit,
    )
from_file(path, channel=None, start=None, end=None, chunk_size=None, ch_labels=None, time_column=0, delimiter=',', header=0) classmethod

Create a ChannelFrame from an audio file.

Parameters:

Name Type Description Default
path str | Path

Path to the audio file.

required
channel int | list[int] | None

Channel(s) to load.

None
start float | None

Start time in seconds.

None
end float | None

End time in seconds.

None
chunk_size int | None

Chunk size for processing. Specifies the splitting size for lazy processing.

None
ch_labels list[str] | None

Labels for each channel.

None
time_column int | str

For CSV files, index or name of the time column. Default is 0 (first column).

0
delimiter str

For CSV files, delimiter character. Default is ",".

','
header int | None

For CSV files, row number to use as header. Default is 0 (first row). Set to None if no header.

0

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the loaded audio data.

Raises:

Type Description
ValueError

If channel specification is invalid.

TypeError

If channel parameter type is invalid.

FileNotFoundError

If the file doesn't exist at the specified path. Error message includes absolute path, current directory, and troubleshooting suggestions.

Examples:

>>> # Load WAV file
>>> cf = ChannelFrame.from_file("audio.wav")
>>> # Load specific channels
>>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
>>> # Load CSV file
>>> cf = ChannelFrame.from_file(
...     "data.csv", time_column=0, delimiter=",", header=0
... )
Source code in wandas/frames/channel.py
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
@classmethod
def from_file(
    cls,
    path: str | Path,
    channel: int | list[int] | None = None,
    start: float | None = None,
    end: float | None = None,
    chunk_size: int | None = None,
    ch_labels: list[str] | None = None,
    # CSV-specific parameters
    time_column: int | str = 0,
    delimiter: str = ",",
    header: int | None = 0,
) -> "ChannelFrame":
    """Create a ChannelFrame from an audio file.

    Args:
        path: Path to the audio file.
        channel: Channel(s) to load.
        start: Start time in seconds.
        end: End time in seconds.
        chunk_size: Chunk size for processing.
            Specifies the splitting size for lazy processing.
        ch_labels: Labels for each channel.
        time_column: For CSV files, index or name of the time column.
            Default is 0 (first column).
        delimiter: For CSV files, delimiter character. Default is ",".
        header: For CSV files, row number to use as header.
            Default is 0 (first row). Set to None if no header.

    Returns:
        A new ChannelFrame containing the loaded audio data.

    Raises:
        ValueError: If channel specification is invalid.
        TypeError: If channel parameter type is invalid.
        FileNotFoundError: If the file doesn't exist at the specified path.
            Error message includes absolute path, current directory, and
            troubleshooting suggestions.

    Examples:
        >>> # Load WAV file
        >>> cf = ChannelFrame.from_file("audio.wav")
        >>> # Load specific channels
        >>> cf = ChannelFrame.from_file("audio.wav", channel=[0, 2])
        >>> # Load CSV file
        >>> cf = ChannelFrame.from_file(
        ...     "data.csv", time_column=0, delimiter=",", header=0
        ... )
    """
    from .channel import ChannelFrame

    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(
            f"Audio file not found\n"
            f"  Path: {path.absolute()}\n"
            f"  Current directory: {Path.cwd()}\n"
            f"Please check:\n"
            f"  - File path is correct\n"
            f"  - File exists at the specified location\n"
            f"  - You have read permissions for the file"
        )

    # Get file reader
    reader = get_file_reader(path)

    # Build kwargs for reader
    reader_kwargs: dict[str, Any] = {}
    if path.suffix.lower() == ".csv":
        reader_kwargs["time_column"] = time_column
        reader_kwargs["delimiter"] = delimiter
        if header is not None:
            reader_kwargs["header"] = header

    # Get file info
    info = reader.get_file_info(path, **reader_kwargs)
    sr = info["samplerate"]
    n_channels = info["channels"]
    n_frames = info["frames"]
    ch_labels = ch_labels or info.get("ch_labels", None)

    logger.debug(f"File info: sr={sr}, channels={n_channels}, frames={n_frames}")

    # Channel selection processing
    all_channels = list(range(n_channels))

    if channel is None:
        channels_to_load = all_channels
        logger.debug(f"Will load all channels: {channels_to_load}")
    elif isinstance(channel, int):
        if channel < 0 or channel >= n_channels:
            raise ValueError(
                f"Channel specification is out of range: {channel} (valid range: 0-{n_channels - 1})"  # noqa: E501
            )
        channels_to_load = [channel]
        logger.debug(f"Will load single channel: {channel}")
    elif isinstance(channel, list | tuple):
        for ch in channel:
            if ch < 0 or ch >= n_channels:
                raise ValueError(
                    f"Channel specification is out of range: {ch} (valid range: 0-{n_channels - 1})"  # noqa: E501
                )
        channels_to_load = list(channel)
        logger.debug(f"Will load specific channels: {channels_to_load}")
    else:
        raise TypeError("channel must be int, list, or None")

    # Index calculation
    start_idx = 0 if start is None else max(0, int(start * sr))
    end_idx = n_frames if end is None else min(n_frames, int(end * sr))
    frames_to_read = end_idx - start_idx

    logger.debug(
        f"Setting up lazy load from file={path}, frames={frames_to_read}, "
        f"start_idx={start_idx}, end_idx={end_idx}"
    )

    # Settings for lazy loading
    expected_shape = (len(channels_to_load), frames_to_read)

    # Define the loading function using the file reader
    def _load_audio() -> NDArrayReal:
        logger.debug(">>> EXECUTING DELAYED LOAD <<<")
        # Use the reader to get audio data with parameters
        out = reader.get_data(
            path, channels_to_load, start_idx, frames_to_read, **reader_kwargs
        )
        if not isinstance(out, np.ndarray):
            raise ValueError("Unexpected data type after reading file")
        return out

    logger.debug(
        f"Creating delayed dask task with expected shape: {expected_shape}"
    )

    # Create delayed operation
    delayed_data = dask_delayed(_load_audio)()
    logger.debug("Wrapping delayed function in dask array")

    # Create dask array from delayed computation
    dask_array = da_from_delayed(
        delayed_data, shape=expected_shape, dtype=np.float32
    )

    if chunk_size is not None:
        if chunk_size <= 0:
            raise ValueError("Chunk size must be a positive integer")
        logger.debug(f"Setting chunk size: {chunk_size} for sample axis")
        dask_array = dask_array.rechunk({0: -1, 1: chunk_size})

    logger.debug(
        "ChannelFrame setup complete - actual file reading will occur on compute()"  # noqa: E501
    )

    cf = ChannelFrame(
        data=dask_array,
        sampling_rate=sr,
        label=path.stem,
        metadata={
            "filename": str(path),
        },
    )
    if ch_labels is not None:
        if len(ch_labels) != len(cf):
            raise ValueError(
                "Number of channel labels does not match the number of specified channels"  # noqa: E501
            )
        for i in range(len(ch_labels)):
            cf._channel_metadata[i].label = ch_labels[i]
    return cf
read_wav(filename, labels=None) classmethod

Utility method to read a WAV file.

Parameters:

Name Type Description Default
filename str

Path to the WAV file.

required
labels list[str] | None

Labels to set for each channel.

None

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data (lazy loading).

Source code in wandas/frames/channel.py
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
@classmethod
def read_wav(cls, filename: str, labels: list[str] | None = None) -> "ChannelFrame":
    """Utility method to read a WAV file.

    Args:
        filename: Path to the WAV file.
        labels: Labels to set for each channel.

    Returns:
        A new ChannelFrame containing the data (lazy loading).
    """
    from .channel import ChannelFrame

    cf = ChannelFrame.from_file(filename, ch_labels=labels)
    return cf
read_csv(filename, time_column=0, labels=None, delimiter=',', header=0) classmethod

Utility method to read a CSV file.

Parameters:

Name Type Description Default
filename str

Path to the CSV file.

required
time_column int | str

Index or name of the time column.

0
labels list[str] | None

Labels to set for each channel.

None
delimiter str

Delimiter character.

','
header int | None

Row number to use as header.

0

Returns:

Type Description
ChannelFrame

A new ChannelFrame containing the data (lazy loading).

Examples:

>>> # Read CSV with default settings
>>> cf = ChannelFrame.read_csv("data.csv")
>>> # Read CSV with custom delimiter
>>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
>>> # Read CSV without header
>>> cf = ChannelFrame.read_csv("data.csv", header=None)
Source code in wandas/frames/channel.py
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
@classmethod
def read_csv(
    cls,
    filename: str,
    time_column: int | str = 0,
    labels: list[str] | None = None,
    delimiter: str = ",",
    header: int | None = 0,
) -> "ChannelFrame":
    """Utility method to read a CSV file.

    Args:
        filename: Path to the CSV file.
        time_column: Index or name of the time column.
        labels: Labels to set for each channel.
        delimiter: Delimiter character.
        header: Row number to use as header.

    Returns:
        A new ChannelFrame containing the data (lazy loading).

    Examples:
        >>> # Read CSV with default settings
        >>> cf = ChannelFrame.read_csv("data.csv")
        >>> # Read CSV with custom delimiter
        >>> cf = ChannelFrame.read_csv("data.csv", delimiter=";")
        >>> # Read CSV without header
        >>> cf = ChannelFrame.read_csv("data.csv", header=None)
    """
    from .channel import ChannelFrame

    cf = ChannelFrame.from_file(
        filename,
        ch_labels=labels,
        time_column=time_column,
        delimiter=delimiter,
        header=header,
    )
    return cf
to_wav(path, format=None)

Save the audio data to a WAV file.

Parameters:

Name Type Description Default
path str | Path

Path to save the file.

required
format str | None

File format. If None, determined from file extension.

None
Source code in wandas/frames/channel.py
942
943
944
945
946
947
948
949
950
951
def to_wav(self, path: str | Path, format: str | None = None) -> None:
    """Save the audio data to a WAV file.

    Args:
        path: Path to save the file.
        format: File format. If None, determined from file extension.
    """
    from wandas.io.wav_io import write_wav

    write_wav(str(path), self, format=format)
save(path, *, format='hdf5', compress='gzip', overwrite=False, dtype=None)

Save the ChannelFrame to a WDF (Wandas Data File) format.

This saves the complete frame including all channel data and metadata in a format that can be loaded back with full fidelity.

Parameters:

Name Type Description Default
path str | Path

Path to save the file. '.wdf' extension will be added if not present.

required
format str

Format to use (currently only 'hdf5' is supported)

'hdf5'
compress str | None

Compression method ('gzip' by default, None for no compression)

'gzip'
overwrite bool

Whether to overwrite existing file

False
dtype str | dtype[Any] | None

Optional data type conversion before saving (e.g. 'float32')

None

Raises:

Type Description
FileExistsError

If the file exists and overwrite=False.

NotImplementedError

For unsupported formats.

Example

cf = ChannelFrame.read_wav("audio.wav") cf.save("audio_analysis.wdf")

Source code in wandas/frames/channel.py
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
def save(
    self,
    path: str | Path,
    *,
    format: str = "hdf5",
    compress: str | None = "gzip",
    overwrite: bool = False,
    dtype: str | np.dtype[Any] | None = None,
) -> None:
    """Save the ChannelFrame to a WDF (Wandas Data File) format.

    This saves the complete frame including all channel data and metadata
    in a format that can be loaded back with full fidelity.

    Args:
        path: Path to save the file. '.wdf' extension will be added if not present.
        format: Format to use (currently only 'hdf5' is supported)
        compress: Compression method ('gzip' by default, None for no compression)
        overwrite: Whether to overwrite existing file
        dtype: Optional data type conversion before saving (e.g. 'float32')

    Raises:
        FileExistsError: If the file exists and overwrite=False.
        NotImplementedError: For unsupported formats.

    Example:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> cf.save("audio_analysis.wdf")
    """
    from ..io.wdf_io import save as wdf_save

    wdf_save(
        self,
        path,
        format=format,
        compress=compress,
        overwrite=overwrite,
        dtype=dtype,
    )
load(path, *, format='hdf5') classmethod

Load a ChannelFrame from a WDF (Wandas Data File) file.

This loads data saved with the save() method, preserving all channel data, metadata, labels, and units.

Parameters:

Name Type Description Default
path str | Path

Path to the WDF file

required
format str

Format of the file (currently only 'hdf5' is supported)

'hdf5'

Returns:

Type Description
ChannelFrame

A new ChannelFrame with all data and metadata loaded

Raises:

Type Description
FileNotFoundError

If the file doesn't exist

NotImplementedError

For unsupported formats

Example

cf = ChannelFrame.load("audio_analysis.wdf")

Source code in wandas/frames/channel.py
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
@classmethod
def load(cls, path: str | Path, *, format: str = "hdf5") -> "ChannelFrame":
    """Load a ChannelFrame from a WDF (Wandas Data File) file.

    This loads data saved with the save() method, preserving all channel data,
    metadata, labels, and units.

    Args:
        path: Path to the WDF file
        format: Format of the file (currently only 'hdf5' is supported)

    Returns:
        A new ChannelFrame with all data and metadata loaded

    Raises:
        FileNotFoundError: If the file doesn't exist
        NotImplementedError: For unsupported formats

    Example:
        >>> cf = ChannelFrame.load("audio_analysis.wdf")
    """
    from ..io.wdf_io import load as wdf_load

    return wdf_load(path, format=format)
add_channel(data, label=None, align='strict', suffix_on_dup=None, inplace=False)

Add a new channel to the frame.

Parameters:

Name Type Description Default
data ndarray[Any, Any] | Array | ChannelFrame

Data to add as a new channel. Can be: - numpy array (1D or 2D) - dask array (1D or 2D) - ChannelFrame (channels will be added)

required
label str | None

Label for the new channel. If None, generates a default label. Ignored when data is a ChannelFrame (uses its channel labels).

None
align str

How to handle length mismatches: - "strict": Raise error if lengths don't match - "pad": Pad shorter data with zeros - "truncate": Truncate longer data to match

'strict'
suffix_on_dup str | None

Suffix to add to duplicate labels. If None, raises error.

None
inplace bool

If True, modifies the frame in place. Otherwise returns a new frame.

False

Returns:

Type Description
ChannelFrame

Modified ChannelFrame (self if inplace=True, new frame otherwise).

Raises:

Type Description
ValueError

If data length doesn't match and align="strict", or if label is duplicate and suffix_on_dup is None.

TypeError

If data type is not supported.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Add a numpy array as a new channel
>>> new_data = np.sin(2 * np.pi * 440 * cf.time)
>>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
>>> # Add another ChannelFrame's channels
>>> cf2 = ChannelFrame.read_wav("audio2.wav")
>>> cf_combined = cf.add_channel(cf2)
Source code in wandas/frames/channel.py
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
def add_channel(
    self,
    data: "np.ndarray[Any, Any] | DaskArray | ChannelFrame",
    label: str | None = None,
    align: str = "strict",
    suffix_on_dup: str | None = None,
    inplace: bool = False,
) -> "ChannelFrame":
    """Add a new channel to the frame.

    Args:
        data: Data to add as a new channel. Can be:
            - numpy array (1D or 2D)
            - dask array (1D or 2D)
            - ChannelFrame (channels will be added)
        label: Label for the new channel. If None, generates a default label.
            Ignored when data is a ChannelFrame (uses its channel labels).
        align: How to handle length mismatches:
            - "strict": Raise error if lengths don't match
            - "pad": Pad shorter data with zeros
            - "truncate": Truncate longer data to match
        suffix_on_dup: Suffix to add to duplicate labels. If None, raises error.
        inplace: If True, modifies the frame in place.
            Otherwise returns a new frame.

    Returns:
        Modified ChannelFrame (self if inplace=True, new frame otherwise).

    Raises:
        ValueError: If data length doesn't match and align="strict",
            or if label is duplicate and suffix_on_dup is None.
        TypeError: If data type is not supported.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Add a numpy array as a new channel
        >>> new_data = np.sin(2 * np.pi * 440 * cf.time)
        >>> cf_new = cf.add_channel(new_data, label="sine_440Hz")
        >>> # Add another ChannelFrame's channels
        >>> cf2 = ChannelFrame.read_wav("audio2.wav")
        >>> cf_combined = cf.add_channel(cf2)
    """
    # ndarray/dask/同型Frame対応
    if isinstance(data, ChannelFrame):
        if self.sampling_rate != data.sampling_rate:
            raise ValueError("sampling_rate不一致")
        if data.n_samples != self.n_samples:
            if align == "pad":
                pad_len = self.n_samples - data.n_samples
                arr = data._data
                if pad_len > 0:
                    arr = concatenate(
                        [
                            arr,
                            from_array(
                                np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                            ),
                        ],
                        axis=1,
                    )
                else:
                    arr = arr[:, : self.n_samples]
            elif align == "truncate":
                arr = data._data[:, : self.n_samples]
                if arr.shape[1] < self.n_samples:
                    pad_len = self.n_samples - arr.shape[1]
                    arr = concatenate(
                        [
                            arr,
                            from_array(
                                np.zeros((arr.shape[0], pad_len), dtype=arr.dtype)
                            ),
                        ],
                        axis=1,
                    )
            else:
                raise ValueError("データ長不一致: align指定を確認")
        else:
            arr = data._data
        labels = [ch.label for ch in self._channel_metadata]
        new_labels = []
        new_metadata_list = []
        for chmeta in data._channel_metadata:
            new_label = chmeta.label
            if new_label in labels or new_label in new_labels:
                if suffix_on_dup:
                    new_label += suffix_on_dup
                else:
                    raise ValueError(f"label重複: {new_label}")
            new_labels.append(new_label)
            # Copy the entire channel_metadata and update only the label
            new_ch_meta = chmeta.model_copy(deep=True)
            new_ch_meta.label = new_label
            new_metadata_list.append(new_ch_meta)
        new_data = concatenate([self._data, arr], axis=0)

        new_chmeta = self._channel_metadata + new_metadata_list
        if inplace:
            self._data = new_data
            self._channel_metadata = new_chmeta
            return self
        else:
            return ChannelFrame(
                data=new_data,
                sampling_rate=self.sampling_rate,
                label=self.label,
                metadata=self.metadata,
                operation_history=self.operation_history,
                channel_metadata=new_chmeta,
                previous=self,
            )
    if isinstance(data, np.ndarray):
        arr = from_array(data.reshape(1, -1))
    elif isinstance(data, DaskArray):
        arr = data[None, ...] if data.ndim == 1 else data
        if arr.shape[0] != 1:
            arr = arr.reshape((1, -1))
    else:
        raise TypeError("add_channel: ndarray/dask/同型Frameのみ対応")
    if arr.shape[1] != self.n_samples:
        if align == "pad":
            pad_len = self.n_samples - arr.shape[1]
            if pad_len > 0:
                arr = concatenate(
                    [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                    axis=1,
                )
            else:
                arr = arr[:, : self.n_samples]
        elif align == "truncate":
            arr = arr[:, : self.n_samples]
            if arr.shape[1] < self.n_samples:
                pad_len = self.n_samples - arr.shape[1]
                arr = concatenate(
                    [arr, from_array(np.zeros((1, pad_len), dtype=arr.dtype))],
                    axis=1,
                )
        else:
            raise ValueError("データ長不一致: align指定を確認")
    labels = [ch.label for ch in self._channel_metadata]
    new_label = label or f"ch{len(labels)}"
    if new_label in labels:
        if suffix_on_dup:
            new_label += suffix_on_dup
        else:
            raise ValueError("label重複")
    new_data = concatenate([self._data, arr], axis=0)
    from ..core.metadata import ChannelMetadata

    new_chmeta = self._channel_metadata + [ChannelMetadata(label=new_label)]
    if inplace:
        self._data = new_data
        self._channel_metadata = new_chmeta
        return self
    else:
        return ChannelFrame(
            data=new_data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=new_chmeta,
            previous=self,
        )
remove_channel(key, inplace=False)
Source code in wandas/frames/channel.py
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
def remove_channel(self, key: int | str, inplace: bool = False) -> "ChannelFrame":
    if isinstance(key, int):
        if not (0 <= key < self.n_channels):
            raise IndexError(f"index {key} out of range")
        idx = key
    else:
        labels = [ch.label for ch in self._channel_metadata]
        if key not in labels:
            raise KeyError(f"label {key} not found")
        idx = labels.index(key)
    new_data = self._data[[i for i in range(self.n_channels) if i != idx], :]
    new_chmeta = [ch for i, ch in enumerate(self._channel_metadata) if i != idx]
    if inplace:
        self._data = new_data
        self._channel_metadata = new_chmeta
        return self
    else:
        return ChannelFrame(
            data=new_data,
            sampling_rate=self.sampling_rate,
            label=self.label,
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=new_chmeta,
            previous=self,
        )
Functions

mixins

Channel frame mixins module.

Attributes
__all__ = ['ChannelProcessingMixin', 'ChannelTransformMixin'] module-attribute
Classes
ChannelProcessingMixin

Mixin that provides methods related to signal processing.

This mixin provides processing methods applied to audio signals and other time-series data, such as signal processing filters and transformation operations.

Source code in wandas/frames/mixins/channel_processing_mixin.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
class ChannelProcessingMixin:
    """Mixin that provides methods related to signal processing.

    This mixin provides processing methods applied to audio signals and
    other time-series data, such as signal processing filters and
    transformation operations.
    """

    def high_pass_filter(
        self: T_Processing, cutoff: float, order: int = 4
    ) -> T_Processing:
        """Apply a high-pass filter to the signal.

        Args:
            cutoff: Filter cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(
            f"Setting up highpass filter: cutoff={cutoff}, order={order} (lazy)"
        )
        result = self.apply_operation("highpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    def low_pass_filter(
        self: T_Processing, cutoff: float, order: int = 4
    ) -> T_Processing:
        """Apply a low-pass filter to the signal.

        Args:
            cutoff: Filter cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(
            f"Setting up lowpass filter: cutoff={cutoff}, order={order} (lazy)"
        )
        result = self.apply_operation("lowpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    def band_pass_filter(
        self: T_Processing, low_cutoff: float, high_cutoff: float, order: int = 4
    ) -> T_Processing:
        """Apply a band-pass filter to the signal.

        Args:
            low_cutoff: Lower cutoff frequency (Hz)
            high_cutoff: Higher cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(
            f"Setting up bandpass filter: low_cutoff={low_cutoff}, "
            f"high_cutoff={high_cutoff}, order={order} (lazy)"
        )
        result = self.apply_operation(
            "bandpass_filter",
            low_cutoff=low_cutoff,
            high_cutoff=high_cutoff,
            order=order,
        )
        return cast(T_Processing, result)

    def normalize(
        self: T_Processing,
        norm: float | None = float("inf"),
        axis: int | None = -1,
        threshold: float | None = None,
        fill: bool | None = None,
    ) -> T_Processing:
        """Normalize signal levels using librosa.util.normalize.

        This method normalizes the signal amplitude according to the specified norm.

        Args:
            norm: Norm type. Default is np.inf (maximum absolute value normalization).
                Supported values:
                - np.inf: Maximum absolute value normalization
                - -np.inf: Minimum absolute value normalization
                - 0: Peak normalization
                - float: Lp norm
                - None: No normalization
            axis: Axis along which to normalize. Default is -1 (time axis).
                - -1: Normalize along time axis (each channel independently)
                - None: Global normalization across all axes
                - int: Normalize along specified axis
            threshold: Threshold below which values are considered zero.
                If None, no threshold is applied.
            fill: Value to fill when the norm is zero.
                If None, the zero vector remains zero.

        Returns:
            New ChannelFrame containing the normalized signal

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> # Normalize to maximum absolute value of 1.0 (per channel)
            >>> normalized = signal.normalize()
            >>> # Global normalization across all channels
            >>> normalized_global = signal.normalize(axis=None)
            >>> # L2 normalization
            >>> normalized_l2 = signal.normalize(norm=2)
        """
        logger.debug(
            f"Setting up normalize: norm={norm}, axis={axis}, "
            f"threshold={threshold}, fill={fill} (lazy)"
        )
        result = self.apply_operation(
            "normalize", norm=norm, axis=axis, threshold=threshold, fill=fill
        )
        return cast(T_Processing, result)

    def remove_dc(self: T_Processing) -> T_Processing:
        """Remove DC component (DC offset) from the signal.

        This method removes the DC (direct current) component by subtracting
        the mean value from each channel. This is equivalent to centering the
        signal around zero.

        Returns:
            New ChannelFrame with DC component removed

        Examples:
            >>> import wandas as wd
            >>> import numpy as np
            >>> # Create signal with DC offset
            >>> signal = wd.read_wav("audio.wav")
            >>> signal_with_dc = signal + 2.0  # Add DC offset
            >>> # Remove DC offset
            >>> signal_clean = signal_with_dc.remove_dc()
            >>> # Verify DC removal
            >>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)

        Notes:
            - This operation is performed per channel
            - Equivalent to applying a high-pass filter with very low cutoff
            - Useful for removing sensor drift or measurement offset
        """
        logger.debug("Setting up DC removal (lazy)")
        result = self.apply_operation("remove_dc")
        return cast(T_Processing, result)

    def a_weighting(self: T_Processing) -> T_Processing:
        """Apply A-weighting filter to the signal.

        A-weighting adjusts the frequency response to approximate human
        auditory perception, according to the IEC 61672-1:2013 standard.

        Returns:
            New ChannelFrame containing the A-weighted signal
        """
        result = self.apply_operation("a_weighting")
        return cast(T_Processing, result)

    def abs(self: T_Processing) -> T_Processing:
        """Compute the absolute value of the signal.

        Returns:
            New ChannelFrame containing the absolute values
        """
        result = self.apply_operation("abs")
        return cast(T_Processing, result)

    def power(self: T_Processing, exponent: float = 2.0) -> T_Processing:
        """Compute the power of the signal.

        Args:
            exponent: Exponent to raise the signal to. Default is 2.0.

        Returns:
            New ChannelFrame containing the powered signal
        """
        result = self.apply_operation("power", exponent=exponent)
        return cast(T_Processing, result)

    def _reduce_channels(self: T_Processing, op: str) -> T_Processing:
        """Helper to reduce all channels with the given operation ('sum' or 'mean')."""
        if op == "sum":
            reduced_data = self._data.sum(axis=0, keepdims=True)
            label = "sum"
        elif op == "mean":
            reduced_data = self._data.mean(axis=0, keepdims=True)
            label = "mean"
        else:
            raise ValueError(f"Unsupported reduction operation: {op}")

        units = [ch.unit for ch in self._channel_metadata]
        if all(u == units[0] for u in units):
            reduced_unit = units[0]
        else:
            reduced_unit = ""

        reduced_extra = {"source_extras": [ch.extra for ch in self._channel_metadata]}
        new_channel_metadata = [
            ChannelMetadata(
                label=label,
                unit=reduced_unit,
                extra=reduced_extra,
            )
        ]
        new_history = (
            self.operation_history.copy() if hasattr(self, "operation_history") else []
        )
        new_history.append({"operation": op})
        new_metadata = self.metadata.copy() if hasattr(self, "metadata") else {}
        result = self._create_new_instance(
            data=reduced_data,
            metadata=new_metadata,
            operation_history=new_history,
            channel_metadata=new_channel_metadata,
        )
        return result

    def sum(self: T_Processing) -> T_Processing:
        """Sum all channels.

        Returns:
            A new ChannelFrame with summed signal.
        """
        return cast(T_Processing, cast(Any, self)._reduce_channels("sum"))

    def mean(self: T_Processing) -> T_Processing:
        """Average all channels.

        Returns:
            A new ChannelFrame with averaged signal.
        """
        return cast(T_Processing, cast(Any, self)._reduce_channels("mean"))

    def trim(
        self: T_Processing,
        start: float = 0,
        end: float | None = None,
    ) -> T_Processing:
        """Trim the signal to the specified time range.

        Args:
            start: Start time (seconds)
            end: End time (seconds)

        Returns:
            New ChannelFrame containing the trimmed signal

        Raises:
            ValueError: If end time is earlier than start time
        """
        if end is None:
            end = self.duration
        if start > end:
            raise ValueError("start must be less than end")
        result = self.apply_operation("trim", start=start, end=end)
        return cast(T_Processing, result)

    def fix_length(
        self: T_Processing,
        length: int | None = None,
        duration: float | None = None,
    ) -> T_Processing:
        """Adjust the signal to the specified length.

        Args:
            duration: Signal length in seconds
            length: Signal length in samples

        Returns:
            New ChannelFrame containing the adjusted signal
        """

        result = self.apply_operation("fix_length", length=length, duration=duration)
        return cast(T_Processing, result)

    def rms_trend(
        self: T_Processing,
        frame_length: int = 2048,
        hop_length: int = 512,
        dB: bool = False,  # noqa: N803
        Aw: bool = False,  # noqa: N803
    ) -> T_Processing:
        """Compute the RMS trend of the signal.

        This method calculates the root mean square value over a sliding window.

        Args:
            frame_length: Size of the sliding window in samples. Default is 2048.
            hop_length: Hop length between windows in samples. Default is 512.
            dB: Whether to return RMS values in decibels. Default is False.
            Aw: Whether to apply A-weighting. Default is False.

        Returns:
            New ChannelFrame containing the RMS trend
        """
        # Access _channel_metadata to retrieve reference values
        frame = cast(ProcessingFrameProtocol, self)

        # Ensure _channel_metadata exists before referencing
        ref_values = []
        if hasattr(frame, "_channel_metadata") and frame._channel_metadata:
            ref_values = [ch.ref for ch in frame._channel_metadata]

        result = self.apply_operation(
            "rms_trend",
            frame_length=frame_length,
            hop_length=hop_length,
            ref=ref_values,
            dB=dB,
            Aw=Aw,
        )

        # Sampling rate update is handled by the Operation class
        return cast(T_Processing, result)

    def channel_difference(
        self: T_Processing, other_channel: int | str = 0
    ) -> T_Processing:
        """Compute the difference between channels.

        Args:
            other_channel: Index or label of the reference channel. Default is 0.

        Returns:
            New ChannelFrame containing the channel difference
        """
        # label2index is a method of BaseFrame
        if isinstance(other_channel, str):
            if hasattr(self, "label2index"):
                other_channel = self.label2index(other_channel)

        result = self.apply_operation("channel_difference", other_channel=other_channel)
        return cast(T_Processing, result)

    def resampling(
        self: T_Processing,
        target_sr: float,
        **kwargs: Any,
    ) -> T_Processing:
        """Resample audio data.

        Args:
            target_sr: Target sampling rate (Hz)
            **kwargs: Additional resampling parameters

        Returns:
            Resampled ChannelFrame
        """
        return cast(
            T_Processing,
            self.apply_operation(
                "resampling",
                target_sr=target_sr,
                **kwargs,
            ),
        )

    def hpss_harmonic(
        self: T_Processing,
        kernel_size: Union[
            "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
        ] = 31,
        power: float = 2,
        margin: Union[
            "_FloatLike_co",
            tuple["_FloatLike_co", "_FloatLike_co"],
            list["_FloatLike_co"],
        ] = 1,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: "_WindowSpec" = "hann",
        center: bool = True,
        pad_mode: "_PadModeSTFT" = "constant",
    ) -> T_Processing:
        """
        Extract harmonic components using HPSS
         (Harmonic-Percussive Source Separation).

        This method separates the harmonic (tonal) components from the signal.

        Args:
            kernel_size: Median filter size for HPSS.
            power: Exponent for the Weiner filter used in HPSS.
            margin: Margin size for the separation.
            n_fft: Size of FFT window.
            hop_length: Hop length for STFT.
            win_length: Window length for STFT.
            window: Window type for STFT.
            center: If True, center the frames.
            pad_mode: Padding mode for STFT.

        Returns:
            A new ChannelFrame containing the harmonic components.
        """
        result = self.apply_operation(
            "hpss_harmonic",
            kernel_size=kernel_size,
            power=power,
            margin=margin,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            center=center,
            pad_mode=pad_mode,
        )
        return cast(T_Processing, result)

    def hpss_percussive(
        self: T_Processing,
        kernel_size: Union[
            "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
        ] = 31,
        power: float = 2,
        margin: Union[
            "_FloatLike_co",
            tuple["_FloatLike_co", "_FloatLike_co"],
            list["_FloatLike_co"],
        ] = 1,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: "_WindowSpec" = "hann",
        center: bool = True,
        pad_mode: "_PadModeSTFT" = "constant",
    ) -> T_Processing:
        """
        Extract percussive components using HPSS
        (Harmonic-Percussive Source Separation).

        This method separates the percussive (tonal) components from the signal.

        Args:
            kernel_size: Median filter size for HPSS.
            power: Exponent for the Weiner filter used in HPSS.
            margin: Margin size for the separation.

        Returns:
            A new ChannelFrame containing the harmonic components.
        """
        result = self.apply_operation(
            "hpss_percussive",
            kernel_size=kernel_size,
            power=power,
            margin=margin,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            center=center,
            pad_mode=pad_mode,
        )
        return cast(T_Processing, result)

    def loudness_zwtv(self: T_Processing, field_type: str = "free") -> T_Processing:
        """
        Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

        This method computes the loudness of non-stationary signals according to
        the Zwicker method, as specified in ISO 532-1:2017. The loudness is
        calculated in sones, where a doubling of sones corresponds to a doubling
        of perceived loudness.

        Args:
            field_type: Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)
                Default is 'free'.

        Returns:
            New ChannelFrame containing time-varying loudness values in sones.
            Each channel is processed independently.
            The output sampling rate is adjusted based on the loudness
            calculation time resolution (typically ~500 Hz for 2ms steps).

        Raises:
            ValueError: If field_type is not 'free' or 'diffuse'

        Examples:
            Calculate loudness for a signal:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> loudness = signal.loudness_zwtv(field_type="free")
            >>> loudness.plot(title="Time-varying Loudness")

            Compare free field and diffuse field:
            >>> loudness_free = signal.loudness_zwtv(field_type="free")
            >>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")

        Notes:
            - The output contains time-varying loudness values in sones
            - Typical loudness: 1 sone ≈ 40 phon (loudness level)
            - The time resolution is approximately 2ms (determined by the algorithm)
            - For multi-channel signals, loudness is calculated per channel
            - The output sampling rate is updated to reflect the time resolution

            **Time axis convention:**
            The time axis in the returned frame represents the start time of
            each 2ms analysis step. This differs slightly from the MoSQITo
            library, which uses the center time of each step. For example:

            - wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
            - MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

            The difference is very small (~1ms) and does not affect the loudness
            values themselves. This design choice ensures consistency with
            wandas's time axis convention across all frame types.

        References:
            ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
            Part 1: Zwicker method"
        """
        result = self.apply_operation("loudness_zwtv", field_type=field_type)

        # Sampling rate update is handled by the Operation class
        return cast(T_Processing, result)

    def loudness_zwst(self: T_Processing, field_type: str = "free") -> "NDArrayReal":
        """
        Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

        This method computes the loudness of stationary (steady) signals according to
        the Zwicker method, as specified in ISO 532-1:2017. The loudness is
        calculated in sones, where a doubling of sones corresponds to a doubling
        of perceived loudness.

        This method is suitable for analyzing steady sounds such as fan noise,
        constant machinery sounds, or other stationary signals.

        Args:
            field_type: Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)
                Default is 'free'.

        Returns:
            Loudness values in sones, one per channel. Shape: (n_channels,)

        Raises:
            ValueError: If field_type is not 'free' or 'diffuse'

        Examples:
            Calculate steady-state loudness for a fan noise:
            >>> import wandas as wd
            >>> signal = wd.read_wav("fan_noise.wav")
            >>> loudness = signal.loudness_zwst(field_type="free")
            >>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
            >>> print(f"Mean loudness: {loudness.mean():.2f} sones")

            Compare free field and diffuse field:
            >>> loudness_free = signal.loudness_zwst(field_type="free")
            >>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
            >>> print(f"Free field: {loudness_free[0]:.2f} sones")
            >>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")

        Notes:
            - Returns a 1D array with one loudness value per channel
            - Typical loudness: 1 sone ≈ 40 phon (loudness level)
            - For multi-channel signals, loudness is calculated independently
              per channel
            - This method is designed for stationary signals (constant sounds)
            - For time-varying signals, use loudness_zwtv() instead
            - Similar to the rms property, returns NDArrayReal for consistency

        References:
            ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
            Part 1: Zwicker method"
        """
        # Treat self as a ProcessingFrameProtocol so mypy understands
        # where sampling_rate and data come from.
        from wandas.processing.psychoacoustic import LoudnessZwst
        from wandas.utils.types import NDArrayReal

        # Create operation instance
        operation = LoudnessZwst(self.sampling_rate, field_type=field_type)

        # Get data (triggers computation if lazy)
        data = self.data

        # Ensure data is 2D (n_channels, n_samples)
        if data.ndim == 1:
            data = data.reshape(1, -1)
        # Process the array using the public API and materialize to NumPy
        result = operation.process_array(data).compute()

        # Squeeze to get 1D array (n_channels,)
        loudness_values: NDArrayReal = result.squeeze()

        # Ensure it's 1D even for single channel
        if loudness_values.ndim == 0:
            loudness_values = loudness_values.reshape(1)

        return loudness_values

    def roughness_dw(self: T_Processing, overlap: float = 0.5) -> T_Processing:
        """Calculate time-varying roughness using Daniel and Weber method.

        Roughness is a psychoacoustic metric that quantifies the perceived
        harshness or roughness of a sound, measured in asper. This method
        implements the Daniel & Weber (1997) standard calculation.

        The calculation follows the standard formula:
        R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

        Args:
            overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
                - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
                - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
                Default is 0.5.

        Returns:
            New ChannelFrame containing time-varying roughness values in asper.
            The output sampling rate depends on the overlap parameter.

        Raises:
            ValueError: If overlap is not in the range [0.0, 1.0]

        Examples:
            Calculate roughness for a motor noise:
            >>> import wandas as wd
            >>> signal = wd.read_wav("motor_noise.wav")
            >>> roughness = signal.roughness_dw(overlap=0.5)
            >>> roughness.plot(ylabel="Roughness [asper]")

            Analyze roughness statistics:
            >>> mean_roughness = roughness.data.mean()
            >>> max_roughness = roughness.data.max()
            >>> print(f"Mean: {mean_roughness:.2f} asper")
            >>> print(f"Max: {max_roughness:.2f} asper")

            Compare before and after modification:
            >>> before = wd.read_wav("motor_before.wav").roughness_dw()
            >>> after = wd.read_wav("motor_after.wav").roughness_dw()
            >>> improvement = before.data.mean() - after.data.mean()
            >>> print(f"Roughness reduction: {improvement:.2f} asper")

        Notes:
            - Returns a ChannelFrame with time-varying roughness values
            - Typical roughness values: 0-2 asper for most sounds
            - Higher values indicate rougher, harsher sounds
            - For multi-channel signals, roughness is calculated independently
              per channel
            - This is the standard-compliant total roughness (R)
            - For detailed Bark-band analysis, use roughness_dw_spec() instead

            **Time axis convention:**
            The time axis in the returned frame represents the start time of
            each 200ms analysis window. This differs from the MoSQITo library,
            which uses the center time of each window. For example:

            - wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
            - MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

            The difference is constant (half the window duration = 100ms) and
            does not affect the roughness values themselves. This design choice
            ensures consistency with wandas's time axis convention across all
            frame types.

        References:
            Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
            Implementation of an optimized model." Acustica, 83, 113-123.
        """
        logger.debug(f"Applying roughness_dw operation with overlap={overlap} (lazy)")
        result = self.apply_operation("roughness_dw", overlap=overlap)
        return cast(T_Processing, result)

    def roughness_dw_spec(self: T_Processing, overlap: float = 0.5) -> "RoughnessFrame":
        """Calculate specific roughness with Bark-band frequency information.

        This method returns detailed roughness analysis data organized by
        Bark frequency bands over time, allowing for frequency-specific
        roughness analysis. It uses the Daniel & Weber (1997) method.

        The relationship between total roughness and specific roughness:
        R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

        Args:
            overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
                - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
                - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
                Default is 0.5.

        Returns:
            RoughnessFrame containing:
                - data: Specific roughness by Bark band, shape (47, n_time)
                        for mono or (n_channels, 47, n_time) for multi-channel
                - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5)
                - time: Time axis for each analysis frame
                - overlap: Overlap coefficient used
                - plot(): Method for Bark-Time heatmap visualization

        Raises:
            ValueError: If overlap is not in the range [0.0, 1.0]

        Examples:
            Analyze frequency-specific roughness:
            >>> import wandas as wd
            >>> import numpy as np
            >>> signal = wd.read_wav("motor.wav")
            >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
            >>>
            >>> # Plot Bark-Time heatmap
            >>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
            >>>
            >>> # Find dominant Bark band
            >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
            >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
            >>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
            >>>
            >>> # Extract specific Bark band time series
            >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
            >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
            >>>
            >>> # Verify standard formula
            >>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
            >>> # This should match signal.roughness_dw(overlap=0.5).data

        Notes:
            - Returns a RoughnessFrame (not ChannelFrame)
            - Contains 47 Bark bands from 0.5 to 23.5 Bark
            - Each Bark band corresponds to a critical band of hearing
            - Useful for identifying which frequencies contribute most to roughness
            - The specific roughness can be integrated to obtain total roughness
            - For simple time-series analysis, use roughness_dw() instead

            **Time axis convention:**
            The time axis represents the start time of each 200ms analysis
            window, consistent with roughness_dw() and other wandas methods.

        References:
            Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
            Implementation of an optimized model." Acustica, 83, 113-123.
        """

        params = {"overlap": overlap}
        operation_name = "roughness_dw_spec"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance via factory
        operation = create_operation(operation_name, self.sampling_rate, **params)

        # Apply processing lazily to self._data (Dask)
        r_spec_dask = operation.process(self._data)

        # Get metadata updates (sampling rate, bark_axis)
        metadata_updates = operation.get_metadata_updates()

        # Build metadata and history
        new_metadata = {**self.metadata, **params}
        new_history = [
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ]

        # Extract bark_axis with proper type handling
        bark_axis_value = metadata_updates.get("bark_axis")
        if bark_axis_value is None:
            raise ValueError("Operation did not provide bark_axis in metadata")

        # Create RoughnessFrame. operation.get_metadata_updates() should provide
        # sampling_rate and bark_axis
        roughness_frame = RoughnessFrame(
            data=r_spec_dask,
            sampling_rate=metadata_updates.get("sampling_rate", self.sampling_rate),
            bark_axis=bark_axis_value,
            overlap=overlap,
            label=f"{self.label}_roughness_spec" if self.label else "roughness_spec",
            metadata=new_metadata,
            operation_history=new_history,
            channel_metadata=self._channel_metadata,
            previous=cast("BaseFrame[NDArrayReal]", self),
        )

        logger.debug(
            "Created RoughnessFrame via operation %s, shape=%s, sampling_rate=%.2f Hz",
            operation_name,
            r_spec_dask.shape,
            roughness_frame.sampling_rate,
        )

        return roughness_frame

    def fade(self: T_Processing, fade_ms: float = 50) -> T_Processing:
        """Apply symmetric fade-in and fade-out to the signal using Tukey window.

        This method applies a symmetric fade-in and fade-out envelope to the signal
        using a Tukey (tapered cosine) window. The fade duration is the same for
        both the beginning and end of the signal.

        Args:
            fade_ms: Fade duration in milliseconds for each end of the signal.
                The total fade duration is 2 * fade_ms. Default is 50 ms.
                Must be positive and less than half the signal duration.

        Returns:
            New ChannelFrame containing the faded signal

        Raises:
            ValueError: If fade_ms is negative or too long for the signal

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> # Apply 10ms fade-in and fade-out
            >>> faded = signal.fade(fade_ms=10.0)
            >>> # Apply very short fade (almost no effect)
            >>> faded_short = signal.fade(fade_ms=0.1)

        Notes:
            - Uses SciPy's Tukey window for smooth fade transitions
            - Fade is applied symmetrically to both ends of the signal
            - The Tukey window alpha parameter is computed automatically
              based on the fade duration and signal length
            - For multi-channel signals, the same fade envelope is applied
              to all channels
            - Lazy evaluation is preserved - computation occurs only when needed
        """
        logger.debug(f"Setting up fade: fade_ms={fade_ms} (lazy)")
        result = self.apply_operation("fade", fade_ms=fade_ms)
        return cast(T_Processing, result)
Functions
high_pass_filter(cutoff, order=4)

Apply a high-pass filter to the signal.

Parameters:

Name Type Description Default
cutoff float

Filter cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def high_pass_filter(
    self: T_Processing, cutoff: float, order: int = 4
) -> T_Processing:
    """Apply a high-pass filter to the signal.

    Args:
        cutoff: Filter cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(
        f"Setting up highpass filter: cutoff={cutoff}, order={order} (lazy)"
    )
    result = self.apply_operation("highpass_filter", cutoff=cutoff, order=order)
    return cast(T_Processing, result)
low_pass_filter(cutoff, order=4)

Apply a low-pass filter to the signal.

Parameters:

Name Type Description Default
cutoff float

Filter cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def low_pass_filter(
    self: T_Processing, cutoff: float, order: int = 4
) -> T_Processing:
    """Apply a low-pass filter to the signal.

    Args:
        cutoff: Filter cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(
        f"Setting up lowpass filter: cutoff={cutoff}, order={order} (lazy)"
    )
    result = self.apply_operation("lowpass_filter", cutoff=cutoff, order=order)
    return cast(T_Processing, result)
band_pass_filter(low_cutoff, high_cutoff, order=4)

Apply a band-pass filter to the signal.

Parameters:

Name Type Description Default
low_cutoff float

Lower cutoff frequency (Hz)

required
high_cutoff float

Higher cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def band_pass_filter(
    self: T_Processing, low_cutoff: float, high_cutoff: float, order: int = 4
) -> T_Processing:
    """Apply a band-pass filter to the signal.

    Args:
        low_cutoff: Lower cutoff frequency (Hz)
        high_cutoff: Higher cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(
        f"Setting up bandpass filter: low_cutoff={low_cutoff}, "
        f"high_cutoff={high_cutoff}, order={order} (lazy)"
    )
    result = self.apply_operation(
        "bandpass_filter",
        low_cutoff=low_cutoff,
        high_cutoff=high_cutoff,
        order=order,
    )
    return cast(T_Processing, result)
normalize(norm=float('inf'), axis=-1, threshold=None, fill=None)

Normalize signal levels using librosa.util.normalize.

This method normalizes the signal amplitude according to the specified norm.

Parameters:

Name Type Description Default
norm float | None

Norm type. Default is np.inf (maximum absolute value normalization). Supported values: - np.inf: Maximum absolute value normalization - -np.inf: Minimum absolute value normalization - 0: Peak normalization - float: Lp norm - None: No normalization

float('inf')
axis int | None

Axis along which to normalize. Default is -1 (time axis). - -1: Normalize along time axis (each channel independently) - None: Global normalization across all axes - int: Normalize along specified axis

-1
threshold float | None

Threshold below which values are considered zero. If None, no threshold is applied.

None
fill bool | None

Value to fill when the norm is zero. If None, the zero vector remains zero.

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the normalized signal

Examples:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> # Normalize to maximum absolute value of 1.0 (per channel)
>>> normalized = signal.normalize()
>>> # Global normalization across all channels
>>> normalized_global = signal.normalize(axis=None)
>>> # L2 normalization
>>> normalized_l2 = signal.normalize(norm=2)
Source code in wandas/frames/mixins/channel_processing_mixin.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def normalize(
    self: T_Processing,
    norm: float | None = float("inf"),
    axis: int | None = -1,
    threshold: float | None = None,
    fill: bool | None = None,
) -> T_Processing:
    """Normalize signal levels using librosa.util.normalize.

    This method normalizes the signal amplitude according to the specified norm.

    Args:
        norm: Norm type. Default is np.inf (maximum absolute value normalization).
            Supported values:
            - np.inf: Maximum absolute value normalization
            - -np.inf: Minimum absolute value normalization
            - 0: Peak normalization
            - float: Lp norm
            - None: No normalization
        axis: Axis along which to normalize. Default is -1 (time axis).
            - -1: Normalize along time axis (each channel independently)
            - None: Global normalization across all axes
            - int: Normalize along specified axis
        threshold: Threshold below which values are considered zero.
            If None, no threshold is applied.
        fill: Value to fill when the norm is zero.
            If None, the zero vector remains zero.

    Returns:
        New ChannelFrame containing the normalized signal

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> # Normalize to maximum absolute value of 1.0 (per channel)
        >>> normalized = signal.normalize()
        >>> # Global normalization across all channels
        >>> normalized_global = signal.normalize(axis=None)
        >>> # L2 normalization
        >>> normalized_l2 = signal.normalize(norm=2)
    """
    logger.debug(
        f"Setting up normalize: norm={norm}, axis={axis}, "
        f"threshold={threshold}, fill={fill} (lazy)"
    )
    result = self.apply_operation(
        "normalize", norm=norm, axis=axis, threshold=threshold, fill=fill
    )
    return cast(T_Processing, result)
remove_dc()

Remove DC component (DC offset) from the signal.

This method removes the DC (direct current) component by subtracting the mean value from each channel. This is equivalent to centering the signal around zero.

Returns:

Type Description
T_Processing

New ChannelFrame with DC component removed

Examples:

>>> import wandas as wd
>>> import numpy as np
>>> # Create signal with DC offset
>>> signal = wd.read_wav("audio.wav")
>>> signal_with_dc = signal + 2.0  # Add DC offset
>>> # Remove DC offset
>>> signal_clean = signal_with_dc.remove_dc()
>>> # Verify DC removal
>>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)
Notes
  • This operation is performed per channel
  • Equivalent to applying a high-pass filter with very low cutoff
  • Useful for removing sensor drift or measurement offset
Source code in wandas/frames/mixins/channel_processing_mixin.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def remove_dc(self: T_Processing) -> T_Processing:
    """Remove DC component (DC offset) from the signal.

    This method removes the DC (direct current) component by subtracting
    the mean value from each channel. This is equivalent to centering the
    signal around zero.

    Returns:
        New ChannelFrame with DC component removed

    Examples:
        >>> import wandas as wd
        >>> import numpy as np
        >>> # Create signal with DC offset
        >>> signal = wd.read_wav("audio.wav")
        >>> signal_with_dc = signal + 2.0  # Add DC offset
        >>> # Remove DC offset
        >>> signal_clean = signal_with_dc.remove_dc()
        >>> # Verify DC removal
        >>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)

    Notes:
        - This operation is performed per channel
        - Equivalent to applying a high-pass filter with very low cutoff
        - Useful for removing sensor drift or measurement offset
    """
    logger.debug("Setting up DC removal (lazy)")
    result = self.apply_operation("remove_dc")
    return cast(T_Processing, result)
a_weighting()

Apply A-weighting filter to the signal.

A-weighting adjusts the frequency response to approximate human auditory perception, according to the IEC 61672-1:2013 standard.

Returns:

Type Description
T_Processing

New ChannelFrame containing the A-weighted signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
174
175
176
177
178
179
180
181
182
183
184
def a_weighting(self: T_Processing) -> T_Processing:
    """Apply A-weighting filter to the signal.

    A-weighting adjusts the frequency response to approximate human
    auditory perception, according to the IEC 61672-1:2013 standard.

    Returns:
        New ChannelFrame containing the A-weighted signal
    """
    result = self.apply_operation("a_weighting")
    return cast(T_Processing, result)
abs()

Compute the absolute value of the signal.

Returns:

Type Description
T_Processing

New ChannelFrame containing the absolute values

Source code in wandas/frames/mixins/channel_processing_mixin.py
186
187
188
189
190
191
192
193
def abs(self: T_Processing) -> T_Processing:
    """Compute the absolute value of the signal.

    Returns:
        New ChannelFrame containing the absolute values
    """
    result = self.apply_operation("abs")
    return cast(T_Processing, result)
power(exponent=2.0)

Compute the power of the signal.

Parameters:

Name Type Description Default
exponent float

Exponent to raise the signal to. Default is 2.0.

2.0

Returns:

Type Description
T_Processing

New ChannelFrame containing the powered signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
195
196
197
198
199
200
201
202
203
204
205
def power(self: T_Processing, exponent: float = 2.0) -> T_Processing:
    """Compute the power of the signal.

    Args:
        exponent: Exponent to raise the signal to. Default is 2.0.

    Returns:
        New ChannelFrame containing the powered signal
    """
    result = self.apply_operation("power", exponent=exponent)
    return cast(T_Processing, result)
sum()

Sum all channels.

Returns:

Type Description
T_Processing

A new ChannelFrame with summed signal.

Source code in wandas/frames/mixins/channel_processing_mixin.py
245
246
247
248
249
250
251
def sum(self: T_Processing) -> T_Processing:
    """Sum all channels.

    Returns:
        A new ChannelFrame with summed signal.
    """
    return cast(T_Processing, cast(Any, self)._reduce_channels("sum"))
mean()

Average all channels.

Returns:

Type Description
T_Processing

A new ChannelFrame with averaged signal.

Source code in wandas/frames/mixins/channel_processing_mixin.py
253
254
255
256
257
258
259
def mean(self: T_Processing) -> T_Processing:
    """Average all channels.

    Returns:
        A new ChannelFrame with averaged signal.
    """
    return cast(T_Processing, cast(Any, self)._reduce_channels("mean"))
trim(start=0, end=None)

Trim the signal to the specified time range.

Parameters:

Name Type Description Default
start float

Start time (seconds)

0
end float | None

End time (seconds)

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the trimmed signal

Raises:

Type Description
ValueError

If end time is earlier than start time

Source code in wandas/frames/mixins/channel_processing_mixin.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def trim(
    self: T_Processing,
    start: float = 0,
    end: float | None = None,
) -> T_Processing:
    """Trim the signal to the specified time range.

    Args:
        start: Start time (seconds)
        end: End time (seconds)

    Returns:
        New ChannelFrame containing the trimmed signal

    Raises:
        ValueError: If end time is earlier than start time
    """
    if end is None:
        end = self.duration
    if start > end:
        raise ValueError("start must be less than end")
    result = self.apply_operation("trim", start=start, end=end)
    return cast(T_Processing, result)
fix_length(length=None, duration=None)

Adjust the signal to the specified length.

Parameters:

Name Type Description Default
duration float | None

Signal length in seconds

None
length int | None

Signal length in samples

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the adjusted signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def fix_length(
    self: T_Processing,
    length: int | None = None,
    duration: float | None = None,
) -> T_Processing:
    """Adjust the signal to the specified length.

    Args:
        duration: Signal length in seconds
        length: Signal length in samples

    Returns:
        New ChannelFrame containing the adjusted signal
    """

    result = self.apply_operation("fix_length", length=length, duration=duration)
    return cast(T_Processing, result)
rms_trend(frame_length=2048, hop_length=512, dB=False, Aw=False)

Compute the RMS trend of the signal.

This method calculates the root mean square value over a sliding window.

Parameters:

Name Type Description Default
frame_length int

Size of the sliding window in samples. Default is 2048.

2048
hop_length int

Hop length between windows in samples. Default is 512.

512
dB bool

Whether to return RMS values in decibels. Default is False.

False
Aw bool

Whether to apply A-weighting. Default is False.

False

Returns:

Type Description
T_Processing

New ChannelFrame containing the RMS trend

Source code in wandas/frames/mixins/channel_processing_mixin.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def rms_trend(
    self: T_Processing,
    frame_length: int = 2048,
    hop_length: int = 512,
    dB: bool = False,  # noqa: N803
    Aw: bool = False,  # noqa: N803
) -> T_Processing:
    """Compute the RMS trend of the signal.

    This method calculates the root mean square value over a sliding window.

    Args:
        frame_length: Size of the sliding window in samples. Default is 2048.
        hop_length: Hop length between windows in samples. Default is 512.
        dB: Whether to return RMS values in decibels. Default is False.
        Aw: Whether to apply A-weighting. Default is False.

    Returns:
        New ChannelFrame containing the RMS trend
    """
    # Access _channel_metadata to retrieve reference values
    frame = cast(ProcessingFrameProtocol, self)

    # Ensure _channel_metadata exists before referencing
    ref_values = []
    if hasattr(frame, "_channel_metadata") and frame._channel_metadata:
        ref_values = [ch.ref for ch in frame._channel_metadata]

    result = self.apply_operation(
        "rms_trend",
        frame_length=frame_length,
        hop_length=hop_length,
        ref=ref_values,
        dB=dB,
        Aw=Aw,
    )

    # Sampling rate update is handled by the Operation class
    return cast(T_Processing, result)
channel_difference(other_channel=0)

Compute the difference between channels.

Parameters:

Name Type Description Default
other_channel int | str

Index or label of the reference channel. Default is 0.

0

Returns:

Type Description
T_Processing

New ChannelFrame containing the channel difference

Source code in wandas/frames/mixins/channel_processing_mixin.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
def channel_difference(
    self: T_Processing, other_channel: int | str = 0
) -> T_Processing:
    """Compute the difference between channels.

    Args:
        other_channel: Index or label of the reference channel. Default is 0.

    Returns:
        New ChannelFrame containing the channel difference
    """
    # label2index is a method of BaseFrame
    if isinstance(other_channel, str):
        if hasattr(self, "label2index"):
            other_channel = self.label2index(other_channel)

    result = self.apply_operation("channel_difference", other_channel=other_channel)
    return cast(T_Processing, result)
resampling(target_sr, **kwargs)

Resample audio data.

Parameters:

Name Type Description Default
target_sr float

Target sampling rate (Hz)

required
**kwargs Any

Additional resampling parameters

{}

Returns:

Type Description
T_Processing

Resampled ChannelFrame

Source code in wandas/frames/mixins/channel_processing_mixin.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def resampling(
    self: T_Processing,
    target_sr: float,
    **kwargs: Any,
) -> T_Processing:
    """Resample audio data.

    Args:
        target_sr: Target sampling rate (Hz)
        **kwargs: Additional resampling parameters

    Returns:
        Resampled ChannelFrame
    """
    return cast(
        T_Processing,
        self.apply_operation(
            "resampling",
            target_sr=target_sr,
            **kwargs,
        ),
    )
hpss_harmonic(kernel_size=31, power=2, margin=1, n_fft=2048, hop_length=None, win_length=None, window='hann', center=True, pad_mode='constant')

Extract harmonic components using HPSS (Harmonic-Percussive Source Separation).

This method separates the harmonic (tonal) components from the signal.

Parameters:

Name Type Description Default
kernel_size Union[_IntLike_co, tuple[_IntLike_co, _IntLike_co], list[_IntLike_co]]

Median filter size for HPSS.

31
power float

Exponent for the Weiner filter used in HPSS.

2
margin Union[_FloatLike_co, tuple[_FloatLike_co, _FloatLike_co], list[_FloatLike_co]]

Margin size for the separation.

1
n_fft int

Size of FFT window.

2048
hop_length int | None

Hop length for STFT.

None
win_length int | None

Window length for STFT.

None
window _WindowSpec

Window type for STFT.

'hann'
center bool

If True, center the frames.

True
pad_mode _PadModeSTFT

Padding mode for STFT.

'constant'

Returns:

Type Description
T_Processing

A new ChannelFrame containing the harmonic components.

Source code in wandas/frames/mixins/channel_processing_mixin.py
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def hpss_harmonic(
    self: T_Processing,
    kernel_size: Union[
        "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
    ] = 31,
    power: float = 2,
    margin: Union[
        "_FloatLike_co",
        tuple["_FloatLike_co", "_FloatLike_co"],
        list["_FloatLike_co"],
    ] = 1,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: "_WindowSpec" = "hann",
    center: bool = True,
    pad_mode: "_PadModeSTFT" = "constant",
) -> T_Processing:
    """
    Extract harmonic components using HPSS
     (Harmonic-Percussive Source Separation).

    This method separates the harmonic (tonal) components from the signal.

    Args:
        kernel_size: Median filter size for HPSS.
        power: Exponent for the Weiner filter used in HPSS.
        margin: Margin size for the separation.
        n_fft: Size of FFT window.
        hop_length: Hop length for STFT.
        win_length: Window length for STFT.
        window: Window type for STFT.
        center: If True, center the frames.
        pad_mode: Padding mode for STFT.

    Returns:
        A new ChannelFrame containing the harmonic components.
    """
    result = self.apply_operation(
        "hpss_harmonic",
        kernel_size=kernel_size,
        power=power,
        margin=margin,
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        center=center,
        pad_mode=pad_mode,
    )
    return cast(T_Processing, result)
hpss_percussive(kernel_size=31, power=2, margin=1, n_fft=2048, hop_length=None, win_length=None, window='hann', center=True, pad_mode='constant')

Extract percussive components using HPSS (Harmonic-Percussive Source Separation).

This method separates the percussive (tonal) components from the signal.

Parameters:

Name Type Description Default
kernel_size Union[_IntLike_co, tuple[_IntLike_co, _IntLike_co], list[_IntLike_co]]

Median filter size for HPSS.

31
power float

Exponent for the Weiner filter used in HPSS.

2
margin Union[_FloatLike_co, tuple[_FloatLike_co, _FloatLike_co], list[_FloatLike_co]]

Margin size for the separation.

1

Returns:

Type Description
T_Processing

A new ChannelFrame containing the harmonic components.

Source code in wandas/frames/mixins/channel_processing_mixin.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def hpss_percussive(
    self: T_Processing,
    kernel_size: Union[
        "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
    ] = 31,
    power: float = 2,
    margin: Union[
        "_FloatLike_co",
        tuple["_FloatLike_co", "_FloatLike_co"],
        list["_FloatLike_co"],
    ] = 1,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: "_WindowSpec" = "hann",
    center: bool = True,
    pad_mode: "_PadModeSTFT" = "constant",
) -> T_Processing:
    """
    Extract percussive components using HPSS
    (Harmonic-Percussive Source Separation).

    This method separates the percussive (tonal) components from the signal.

    Args:
        kernel_size: Median filter size for HPSS.
        power: Exponent for the Weiner filter used in HPSS.
        margin: Margin size for the separation.

    Returns:
        A new ChannelFrame containing the harmonic components.
    """
    result = self.apply_operation(
        "hpss_percussive",
        kernel_size=kernel_size,
        power=power,
        margin=margin,
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        center=center,
        pad_mode=pad_mode,
    )
    return cast(T_Processing, result)
loudness_zwtv(field_type='free')

Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

This method computes the loudness of non-stationary signals according to the Zwicker method, as specified in ISO 532-1:2017. The loudness is calculated in sones, where a doubling of sones corresponds to a doubling of perceived loudness.

Parameters:

Name Type Description Default
field_type str

Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions) Default is 'free'.

'free'

Returns:

Type Description
T_Processing

New ChannelFrame containing time-varying loudness values in sones.

T_Processing

Each channel is processed independently.

T_Processing

The output sampling rate is adjusted based on the loudness

T_Processing

calculation time resolution (typically ~500 Hz for 2ms steps).

Raises:

Type Description
ValueError

If field_type is not 'free' or 'diffuse'

Examples:

Calculate loudness for a signal:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> loudness = signal.loudness_zwtv(field_type="free")
>>> loudness.plot(title="Time-varying Loudness")

Compare free field and diffuse field:

>>> loudness_free = signal.loudness_zwtv(field_type="free")
>>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")
Notes
  • The output contains time-varying loudness values in sones
  • Typical loudness: 1 sone ≈ 40 phon (loudness level)
  • The time resolution is approximately 2ms (determined by the algorithm)
  • For multi-channel signals, loudness is calculated per channel
  • The output sampling rate is updated to reflect the time resolution

Time axis convention: The time axis in the returned frame represents the start time of each 2ms analysis step. This differs slightly from the MoSQITo library, which uses the center time of each step. For example:

  • wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
  • MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

The difference is very small (~1ms) and does not affect the loudness values themselves. This design choice ensures consistency with wandas's time axis convention across all frame types.

References

ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method"

Source code in wandas/frames/mixins/channel_processing_mixin.py
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
def loudness_zwtv(self: T_Processing, field_type: str = "free") -> T_Processing:
    """
    Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

    This method computes the loudness of non-stationary signals according to
    the Zwicker method, as specified in ISO 532-1:2017. The loudness is
    calculated in sones, where a doubling of sones corresponds to a doubling
    of perceived loudness.

    Args:
        field_type: Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)
            Default is 'free'.

    Returns:
        New ChannelFrame containing time-varying loudness values in sones.
        Each channel is processed independently.
        The output sampling rate is adjusted based on the loudness
        calculation time resolution (typically ~500 Hz for 2ms steps).

    Raises:
        ValueError: If field_type is not 'free' or 'diffuse'

    Examples:
        Calculate loudness for a signal:
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> loudness = signal.loudness_zwtv(field_type="free")
        >>> loudness.plot(title="Time-varying Loudness")

        Compare free field and diffuse field:
        >>> loudness_free = signal.loudness_zwtv(field_type="free")
        >>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")

    Notes:
        - The output contains time-varying loudness values in sones
        - Typical loudness: 1 sone ≈ 40 phon (loudness level)
        - The time resolution is approximately 2ms (determined by the algorithm)
        - For multi-channel signals, loudness is calculated per channel
        - The output sampling rate is updated to reflect the time resolution

        **Time axis convention:**
        The time axis in the returned frame represents the start time of
        each 2ms analysis step. This differs slightly from the MoSQITo
        library, which uses the center time of each step. For example:

        - wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
        - MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

        The difference is very small (~1ms) and does not affect the loudness
        values themselves. This design choice ensures consistency with
        wandas's time axis convention across all frame types.

    References:
        ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
        Part 1: Zwicker method"
    """
    result = self.apply_operation("loudness_zwtv", field_type=field_type)

    # Sampling rate update is handled by the Operation class
    return cast(T_Processing, result)
loudness_zwst(field_type='free')

Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

This method computes the loudness of stationary (steady) signals according to the Zwicker method, as specified in ISO 532-1:2017. The loudness is calculated in sones, where a doubling of sones corresponds to a doubling of perceived loudness.

This method is suitable for analyzing steady sounds such as fan noise, constant machinery sounds, or other stationary signals.

Parameters:

Name Type Description Default
field_type str

Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions) Default is 'free'.

'free'

Returns:

Type Description
NDArrayReal

Loudness values in sones, one per channel. Shape: (n_channels,)

Raises:

Type Description
ValueError

If field_type is not 'free' or 'diffuse'

Examples:

Calculate steady-state loudness for a fan noise:

>>> import wandas as wd
>>> signal = wd.read_wav("fan_noise.wav")
>>> loudness = signal.loudness_zwst(field_type="free")
>>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
>>> print(f"Mean loudness: {loudness.mean():.2f} sones")

Compare free field and diffuse field:

>>> loudness_free = signal.loudness_zwst(field_type="free")
>>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
>>> print(f"Free field: {loudness_free[0]:.2f} sones")
>>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")
Notes
  • Returns a 1D array with one loudness value per channel
  • Typical loudness: 1 sone ≈ 40 phon (loudness level)
  • For multi-channel signals, loudness is calculated independently per channel
  • This method is designed for stationary signals (constant sounds)
  • For time-varying signals, use loudness_zwtv() instead
  • Similar to the rms property, returns NDArrayReal for consistency
References

ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method"

Source code in wandas/frames/mixins/channel_processing_mixin.py
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def loudness_zwst(self: T_Processing, field_type: str = "free") -> "NDArrayReal":
    """
    Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

    This method computes the loudness of stationary (steady) signals according to
    the Zwicker method, as specified in ISO 532-1:2017. The loudness is
    calculated in sones, where a doubling of sones corresponds to a doubling
    of perceived loudness.

    This method is suitable for analyzing steady sounds such as fan noise,
    constant machinery sounds, or other stationary signals.

    Args:
        field_type: Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)
            Default is 'free'.

    Returns:
        Loudness values in sones, one per channel. Shape: (n_channels,)

    Raises:
        ValueError: If field_type is not 'free' or 'diffuse'

    Examples:
        Calculate steady-state loudness for a fan noise:
        >>> import wandas as wd
        >>> signal = wd.read_wav("fan_noise.wav")
        >>> loudness = signal.loudness_zwst(field_type="free")
        >>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
        >>> print(f"Mean loudness: {loudness.mean():.2f} sones")

        Compare free field and diffuse field:
        >>> loudness_free = signal.loudness_zwst(field_type="free")
        >>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
        >>> print(f"Free field: {loudness_free[0]:.2f} sones")
        >>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")

    Notes:
        - Returns a 1D array with one loudness value per channel
        - Typical loudness: 1 sone ≈ 40 phon (loudness level)
        - For multi-channel signals, loudness is calculated independently
          per channel
        - This method is designed for stationary signals (constant sounds)
        - For time-varying signals, use loudness_zwtv() instead
        - Similar to the rms property, returns NDArrayReal for consistency

    References:
        ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
        Part 1: Zwicker method"
    """
    # Treat self as a ProcessingFrameProtocol so mypy understands
    # where sampling_rate and data come from.
    from wandas.processing.psychoacoustic import LoudnessZwst
    from wandas.utils.types import NDArrayReal

    # Create operation instance
    operation = LoudnessZwst(self.sampling_rate, field_type=field_type)

    # Get data (triggers computation if lazy)
    data = self.data

    # Ensure data is 2D (n_channels, n_samples)
    if data.ndim == 1:
        data = data.reshape(1, -1)
    # Process the array using the public API and materialize to NumPy
    result = operation.process_array(data).compute()

    # Squeeze to get 1D array (n_channels,)
    loudness_values: NDArrayReal = result.squeeze()

    # Ensure it's 1D even for single channel
    if loudness_values.ndim == 0:
        loudness_values = loudness_values.reshape(1)

    return loudness_values
roughness_dw(overlap=0.5)

Calculate time-varying roughness using Daniel and Weber method.

Roughness is a psychoacoustic metric that quantifies the perceived harshness or roughness of a sound, measured in asper. This method implements the Daniel & Weber (1997) standard calculation.

The calculation follows the standard formula: R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

Parameters:

Name Type Description Default
overlap float

Overlapping coefficient for 200ms analysis windows (0.0 to 1.0). - overlap=0.5: 100ms hop → ~10 Hz output sampling rate - overlap=0.0: 200ms hop → ~5 Hz output sampling rate Default is 0.5.

0.5

Returns:

Type Description
T_Processing

New ChannelFrame containing time-varying roughness values in asper.

T_Processing

The output sampling rate depends on the overlap parameter.

Raises:

Type Description
ValueError

If overlap is not in the range [0.0, 1.0]

Examples:

Calculate roughness for a motor noise:

>>> import wandas as wd
>>> signal = wd.read_wav("motor_noise.wav")
>>> roughness = signal.roughness_dw(overlap=0.5)
>>> roughness.plot(ylabel="Roughness [asper]")

Analyze roughness statistics:

>>> mean_roughness = roughness.data.mean()
>>> max_roughness = roughness.data.max()
>>> print(f"Mean: {mean_roughness:.2f} asper")
>>> print(f"Max: {max_roughness:.2f} asper")

Compare before and after modification:

>>> before = wd.read_wav("motor_before.wav").roughness_dw()
>>> after = wd.read_wav("motor_after.wav").roughness_dw()
>>> improvement = before.data.mean() - after.data.mean()
>>> print(f"Roughness reduction: {improvement:.2f} asper")
Notes
  • Returns a ChannelFrame with time-varying roughness values
  • Typical roughness values: 0-2 asper for most sounds
  • Higher values indicate rougher, harsher sounds
  • For multi-channel signals, roughness is calculated independently per channel
  • This is the standard-compliant total roughness (R)
  • For detailed Bark-band analysis, use roughness_dw_spec() instead

Time axis convention: The time axis in the returned frame represents the start time of each 200ms analysis window. This differs from the MoSQITo library, which uses the center time of each window. For example:

  • wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
  • MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

The difference is constant (half the window duration = 100ms) and does not affect the roughness values themselves. This design choice ensures consistency with wandas's time axis convention across all frame types.

References

Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model." Acustica, 83, 113-123.

Source code in wandas/frames/mixins/channel_processing_mixin.py
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
def roughness_dw(self: T_Processing, overlap: float = 0.5) -> T_Processing:
    """Calculate time-varying roughness using Daniel and Weber method.

    Roughness is a psychoacoustic metric that quantifies the perceived
    harshness or roughness of a sound, measured in asper. This method
    implements the Daniel & Weber (1997) standard calculation.

    The calculation follows the standard formula:
    R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

    Args:
        overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
            - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
            - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
            Default is 0.5.

    Returns:
        New ChannelFrame containing time-varying roughness values in asper.
        The output sampling rate depends on the overlap parameter.

    Raises:
        ValueError: If overlap is not in the range [0.0, 1.0]

    Examples:
        Calculate roughness for a motor noise:
        >>> import wandas as wd
        >>> signal = wd.read_wav("motor_noise.wav")
        >>> roughness = signal.roughness_dw(overlap=0.5)
        >>> roughness.plot(ylabel="Roughness [asper]")

        Analyze roughness statistics:
        >>> mean_roughness = roughness.data.mean()
        >>> max_roughness = roughness.data.max()
        >>> print(f"Mean: {mean_roughness:.2f} asper")
        >>> print(f"Max: {max_roughness:.2f} asper")

        Compare before and after modification:
        >>> before = wd.read_wav("motor_before.wav").roughness_dw()
        >>> after = wd.read_wav("motor_after.wav").roughness_dw()
        >>> improvement = before.data.mean() - after.data.mean()
        >>> print(f"Roughness reduction: {improvement:.2f} asper")

    Notes:
        - Returns a ChannelFrame with time-varying roughness values
        - Typical roughness values: 0-2 asper for most sounds
        - Higher values indicate rougher, harsher sounds
        - For multi-channel signals, roughness is calculated independently
          per channel
        - This is the standard-compliant total roughness (R)
        - For detailed Bark-band analysis, use roughness_dw_spec() instead

        **Time axis convention:**
        The time axis in the returned frame represents the start time of
        each 200ms analysis window. This differs from the MoSQITo library,
        which uses the center time of each window. For example:

        - wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
        - MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

        The difference is constant (half the window duration = 100ms) and
        does not affect the roughness values themselves. This design choice
        ensures consistency with wandas's time axis convention across all
        frame types.

    References:
        Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
        Implementation of an optimized model." Acustica, 83, 113-123.
    """
    logger.debug(f"Applying roughness_dw operation with overlap={overlap} (lazy)")
    result = self.apply_operation("roughness_dw", overlap=overlap)
    return cast(T_Processing, result)
roughness_dw_spec(overlap=0.5)

Calculate specific roughness with Bark-band frequency information.

This method returns detailed roughness analysis data organized by Bark frequency bands over time, allowing for frequency-specific roughness analysis. It uses the Daniel & Weber (1997) method.

The relationship between total roughness and specific roughness: R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

Parameters:

Name Type Description Default
overlap float

Overlapping coefficient for 200ms analysis windows (0.0 to 1.0). - overlap=0.5: 100ms hop → ~10 Hz output sampling rate - overlap=0.0: 200ms hop → ~5 Hz output sampling rate Default is 0.5.

0.5

Returns:

Type Description
RoughnessFrame

RoughnessFrame containing: - data: Specific roughness by Bark band, shape (47, n_time) for mono or (n_channels, 47, n_time) for multi-channel - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5) - time: Time axis for each analysis frame - overlap: Overlap coefficient used - plot(): Method for Bark-Time heatmap visualization

Raises:

Type Description
ValueError

If overlap is not in the range [0.0, 1.0]

Examples:

Analyze frequency-specific roughness:

>>> import wandas as wd
>>> import numpy as np
>>> signal = wd.read_wav("motor.wav")
>>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
>>>
>>> # Plot Bark-Time heatmap
>>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
>>>
>>> # Find dominant Bark band
>>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
>>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
>>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
>>>
>>> # Extract specific Bark band time series
>>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
>>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
>>>
>>> # Verify standard formula
>>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
>>> # This should match signal.roughness_dw(overlap=0.5).data
Notes
  • Returns a RoughnessFrame (not ChannelFrame)
  • Contains 47 Bark bands from 0.5 to 23.5 Bark
  • Each Bark band corresponds to a critical band of hearing
  • Useful for identifying which frequencies contribute most to roughness
  • The specific roughness can be integrated to obtain total roughness
  • For simple time-series analysis, use roughness_dw() instead

Time axis convention: The time axis represents the start time of each 200ms analysis window, consistent with roughness_dw() and other wandas methods.

References

Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model." Acustica, 83, 113-123.

Source code in wandas/frames/mixins/channel_processing_mixin.py
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
def roughness_dw_spec(self: T_Processing, overlap: float = 0.5) -> "RoughnessFrame":
    """Calculate specific roughness with Bark-band frequency information.

    This method returns detailed roughness analysis data organized by
    Bark frequency bands over time, allowing for frequency-specific
    roughness analysis. It uses the Daniel & Weber (1997) method.

    The relationship between total roughness and specific roughness:
    R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

    Args:
        overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
            - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
            - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
            Default is 0.5.

    Returns:
        RoughnessFrame containing:
            - data: Specific roughness by Bark band, shape (47, n_time)
                    for mono or (n_channels, 47, n_time) for multi-channel
            - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5)
            - time: Time axis for each analysis frame
            - overlap: Overlap coefficient used
            - plot(): Method for Bark-Time heatmap visualization

    Raises:
        ValueError: If overlap is not in the range [0.0, 1.0]

    Examples:
        Analyze frequency-specific roughness:
        >>> import wandas as wd
        >>> import numpy as np
        >>> signal = wd.read_wav("motor.wav")
        >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
        >>>
        >>> # Plot Bark-Time heatmap
        >>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
        >>>
        >>> # Find dominant Bark band
        >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
        >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
        >>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
        >>>
        >>> # Extract specific Bark band time series
        >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
        >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
        >>>
        >>> # Verify standard formula
        >>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
        >>> # This should match signal.roughness_dw(overlap=0.5).data

    Notes:
        - Returns a RoughnessFrame (not ChannelFrame)
        - Contains 47 Bark bands from 0.5 to 23.5 Bark
        - Each Bark band corresponds to a critical band of hearing
        - Useful for identifying which frequencies contribute most to roughness
        - The specific roughness can be integrated to obtain total roughness
        - For simple time-series analysis, use roughness_dw() instead

        **Time axis convention:**
        The time axis represents the start time of each 200ms analysis
        window, consistent with roughness_dw() and other wandas methods.

    References:
        Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
        Implementation of an optimized model." Acustica, 83, 113-123.
    """

    params = {"overlap": overlap}
    operation_name = "roughness_dw_spec"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance via factory
    operation = create_operation(operation_name, self.sampling_rate, **params)

    # Apply processing lazily to self._data (Dask)
    r_spec_dask = operation.process(self._data)

    # Get metadata updates (sampling rate, bark_axis)
    metadata_updates = operation.get_metadata_updates()

    # Build metadata and history
    new_metadata = {**self.metadata, **params}
    new_history = [
        *self.operation_history,
        {"operation": operation_name, "params": params},
    ]

    # Extract bark_axis with proper type handling
    bark_axis_value = metadata_updates.get("bark_axis")
    if bark_axis_value is None:
        raise ValueError("Operation did not provide bark_axis in metadata")

    # Create RoughnessFrame. operation.get_metadata_updates() should provide
    # sampling_rate and bark_axis
    roughness_frame = RoughnessFrame(
        data=r_spec_dask,
        sampling_rate=metadata_updates.get("sampling_rate", self.sampling_rate),
        bark_axis=bark_axis_value,
        overlap=overlap,
        label=f"{self.label}_roughness_spec" if self.label else "roughness_spec",
        metadata=new_metadata,
        operation_history=new_history,
        channel_metadata=self._channel_metadata,
        previous=cast("BaseFrame[NDArrayReal]", self),
    )

    logger.debug(
        "Created RoughnessFrame via operation %s, shape=%s, sampling_rate=%.2f Hz",
        operation_name,
        r_spec_dask.shape,
        roughness_frame.sampling_rate,
    )

    return roughness_frame
fade(fade_ms=50)

Apply symmetric fade-in and fade-out to the signal using Tukey window.

This method applies a symmetric fade-in and fade-out envelope to the signal using a Tukey (tapered cosine) window. The fade duration is the same for both the beginning and end of the signal.

Parameters:

Name Type Description Default
fade_ms float

Fade duration in milliseconds for each end of the signal. The total fade duration is 2 * fade_ms. Default is 50 ms. Must be positive and less than half the signal duration.

50

Returns:

Type Description
T_Processing

New ChannelFrame containing the faded signal

Raises:

Type Description
ValueError

If fade_ms is negative or too long for the signal

Examples:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> # Apply 10ms fade-in and fade-out
>>> faded = signal.fade(fade_ms=10.0)
>>> # Apply very short fade (almost no effect)
>>> faded_short = signal.fade(fade_ms=0.1)
Notes
  • Uses SciPy's Tukey window for smooth fade transitions
  • Fade is applied symmetrically to both ends of the signal
  • The Tukey window alpha parameter is computed automatically based on the fade duration and signal length
  • For multi-channel signals, the same fade envelope is applied to all channels
  • Lazy evaluation is preserved - computation occurs only when needed
Source code in wandas/frames/mixins/channel_processing_mixin.py
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
def fade(self: T_Processing, fade_ms: float = 50) -> T_Processing:
    """Apply symmetric fade-in and fade-out to the signal using Tukey window.

    This method applies a symmetric fade-in and fade-out envelope to the signal
    using a Tukey (tapered cosine) window. The fade duration is the same for
    both the beginning and end of the signal.

    Args:
        fade_ms: Fade duration in milliseconds for each end of the signal.
            The total fade duration is 2 * fade_ms. Default is 50 ms.
            Must be positive and less than half the signal duration.

    Returns:
        New ChannelFrame containing the faded signal

    Raises:
        ValueError: If fade_ms is negative or too long for the signal

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> # Apply 10ms fade-in and fade-out
        >>> faded = signal.fade(fade_ms=10.0)
        >>> # Apply very short fade (almost no effect)
        >>> faded_short = signal.fade(fade_ms=0.1)

    Notes:
        - Uses SciPy's Tukey window for smooth fade transitions
        - Fade is applied symmetrically to both ends of the signal
        - The Tukey window alpha parameter is computed automatically
          based on the fade duration and signal length
        - For multi-channel signals, the same fade envelope is applied
          to all channels
        - Lazy evaluation is preserved - computation occurs only when needed
    """
    logger.debug(f"Setting up fade: fade_ms={fade_ms} (lazy)")
    result = self.apply_operation("fade", fade_ms=fade_ms)
    return cast(T_Processing, result)
ChannelTransformMixin

Mixin providing methods related to frequency transformations.

This mixin provides operations related to frequency analysis and transformations such as FFT, STFT, and Welch method.

Source code in wandas/frames/mixins/channel_transform_mixin.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
class ChannelTransformMixin:
    """Mixin providing methods related to frequency transformations.

    This mixin provides operations related to frequency analysis and
    transformations such as FFT, STFT, and Welch method.
    """

    def fft(
        self: T_Transform, n_fft: int | None = None, window: str = "hann"
    ) -> "SpectralFrame":
        """Calculate Fast Fourier Transform (FFT).

        Args:
            n_fft: Number of FFT points. Default is the next power of 2 of the data
                length.
            window: Window type. Default is "hann".

        Returns:
            SpectralFrame containing FFT results
        """
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import FFT, create_operation

        params = {"n_fft": n_fft, "window": window}
        operation_name = "fft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("FFT", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        if n_fft is None:
            is_even = spectrum_data.shape[-1] % 2 == 0
            _n_fft = (
                spectrum_data.shape[-1] * 2 - 2
                if is_even
                else spectrum_data.shape[-1] * 2 - 1
            )
        else:
            _n_fft = n_fft

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        return SpectralFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            n_fft=_n_fft,
            window=operation.window,
            label=f"Spectrum of {self.label}",
            metadata={**self.metadata, "window": window, "n_fft": _n_fft},
            operation_history=[
                *self.operation_history,
                {"operation": "fft", "params": {"n_fft": _n_fft, "window": window}},
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def welch(
        self: T_Transform,
        n_fft: int | None = None,
        hop_length: int | None = None,
        win_length: int = 2048,
        window: str = "hann",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Calculate power spectral density using Welch's method.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            average: Method for averaging segments. Default is "mean".

        Returns:
            SpectralFrame containing power spectral density
        """
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import Welch, create_operation

        params = dict(
            n_fft=n_fft or win_length,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            average=average,
        )
        operation_name = "welch"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("Welch", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        return SpectralFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Spectrum of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": "welch", "params": params},
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def noct_spectrum(
        self: T_Transform,
        fmin: float = 25,
        fmax: float = 20000,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ) -> "NOctFrame":
        """Calculate N-octave band spectrum.

        Args:
            fmin: Minimum center frequency (Hz). Default is 25 Hz.
            fmax: Maximum center frequency (Hz). Default is 20000 Hz.
            n: Band division (1: octave, 3: 1/3 octave). Default is 3.
            G: Reference gain (dB). Default is 10 dB.
            fr: Reference frequency (Hz). Default is 1000 Hz.

        Returns:
            NOctFrame containing N-octave band spectrum
        """
        from wandas.processing import NOctSpectrum, create_operation

        from ..noct import NOctFrame

        params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
        operation_name = "noct_spectrum"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("NOctSpectrum", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        return NOctFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            fmin=fmin,
            fmax=fmax,
            n=n,
            G=G,
            fr=fr,
            label=f"1/{n}Oct of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {
                    "operation": "noct_spectrum",
                    "params": params,
                },
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def stft(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
    ) -> "SpectrogramFrame":
        """Calculate Short-Time Fourier Transform.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".

        Returns:
            SpectrogramFrame containing STFT results
        """
        from wandas.processing import STFT, create_operation

        from ..spectrogram import SpectrogramFrame

        # Set hop length and window length
        _hop_length = hop_length if hop_length is not None else n_fft // 4
        _win_length = win_length if win_length is not None else n_fft

        params = {
            "n_fft": n_fft,
            "hop_length": _hop_length,
            "win_length": _win_length,
            "window": window,
        }
        operation_name = "stft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("STFT", operation)

        # Apply processing to data
        spectrogram_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectrogramFrame with operation {operation_name} added to graph"  # noqa: E501
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new instance
        return SpectrogramFrame(
            data=spectrogram_data,
            sampling_rate=self.sampling_rate,
            n_fft=n_fft,
            hop_length=_hop_length,
            win_length=_win_length,
            window=window,
            label=f"stft({self.label})",
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def coherence(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
    ) -> "SpectralFrame":
        """Calculate magnitude squared coherence.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.

        Returns:
            SpectralFrame containing magnitude squared coherence
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.processing import Coherence, create_operation

        from ..spectral import SpectralFrame

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
        }
        operation_name = "coherence"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("Coherence", operation)

        # Apply processing to data
        coherence_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"$\\gamma_{{{in_ch.label}, {out_ch.label}}}$"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(
                    in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
                )
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=coherence_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Coherence of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )

    def csd(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Calculate cross-spectral density matrix.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.
            scaling: Scaling method. Options: "spectrum", "density".
            average: Method for averaging segments. Default is "mean".

        Returns:
            SpectralFrame containing cross-spectral density matrix
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import CSD, create_operation

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
            "scaling": scaling,
            "average": average,
        }
        operation_name = "csd"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("CSD", operation)

        # Apply processing to data
        csd_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"{operation_name}({in_ch.label}, {out_ch.label})"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(
                    in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
                )
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=csd_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"$C_{{{in_ch.label}, {out_ch.label}}}$",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )

    def transfer_function(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Calculate transfer function matrix.

        The transfer function represents the signal transfer characteristics between
        channels in the frequency domain and represents the input-output relationship
        of the system.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.
            scaling: Scaling method. Options: "spectrum", "density".
            average: Method for averaging segments. Default is "mean".

        Returns:
            SpectralFrame containing transfer function matrix
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import TransferFunction, create_operation

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
            "scaling": scaling,
            "average": average,
        }
        operation_name = "transfer_function"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("TransferFunction", operation)

        # Apply processing to data
        tf_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"$H_{{{in_ch.label}, {out_ch.label}}}$"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(
                    in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
                )
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=tf_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Transfer function of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )
Functions
fft(n_fft=None, window='hann')

Calculate Fast Fourier Transform (FFT).

Parameters:

Name Type Description Default
n_fft int | None

Number of FFT points. Default is the next power of 2 of the data length.

None
window str

Window type. Default is "hann".

'hann'

Returns:

Type Description
SpectralFrame

SpectralFrame containing FFT results

Source code in wandas/frames/mixins/channel_transform_mixin.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def fft(
    self: T_Transform, n_fft: int | None = None, window: str = "hann"
) -> "SpectralFrame":
    """Calculate Fast Fourier Transform (FFT).

    Args:
        n_fft: Number of FFT points. Default is the next power of 2 of the data
            length.
        window: Window type. Default is "hann".

    Returns:
        SpectralFrame containing FFT results
    """
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import FFT, create_operation

    params = {"n_fft": n_fft, "window": window}
    operation_name = "fft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("FFT", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    if n_fft is None:
        is_even = spectrum_data.shape[-1] % 2 == 0
        _n_fft = (
            spectrum_data.shape[-1] * 2 - 2
            if is_even
            else spectrum_data.shape[-1] * 2 - 1
        )
    else:
        _n_fft = n_fft

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    return SpectralFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        n_fft=_n_fft,
        window=operation.window,
        label=f"Spectrum of {self.label}",
        metadata={**self.metadata, "window": window, "n_fft": _n_fft},
        operation_history=[
            *self.operation_history,
            {"operation": "fft", "params": {"n_fft": _n_fft, "window": window}},
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
welch(n_fft=None, hop_length=None, win_length=2048, window='hann', average='mean')

Calculate power spectral density using Welch's method.

Parameters:

Name Type Description Default
n_fft int | None

Number of FFT points. Default is 2048.

None
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int

Window length. Default is n_fft.

2048
window str

Window type. Default is "hann".

'hann'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

SpectralFrame containing power spectral density

Source code in wandas/frames/mixins/channel_transform_mixin.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def welch(
    self: T_Transform,
    n_fft: int | None = None,
    hop_length: int | None = None,
    win_length: int = 2048,
    window: str = "hann",
    average: str = "mean",
) -> "SpectralFrame":
    """Calculate power spectral density using Welch's method.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        average: Method for averaging segments. Default is "mean".

    Returns:
        SpectralFrame containing power spectral density
    """
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import Welch, create_operation

    params = dict(
        n_fft=n_fft or win_length,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        average=average,
    )
    operation_name = "welch"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("Welch", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    return SpectralFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Spectrum of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": "welch", "params": params},
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
noct_spectrum(fmin=25, fmax=20000, n=3, G=10, fr=1000)

Calculate N-octave band spectrum.

Parameters:

Name Type Description Default
fmin float

Minimum center frequency (Hz). Default is 25 Hz.

25
fmax float

Maximum center frequency (Hz). Default is 20000 Hz.

20000
n int

Band division (1: octave, 3: 1/3 octave). Default is 3.

3
G int

Reference gain (dB). Default is 10 dB.

10
fr int

Reference frequency (Hz). Default is 1000 Hz.

1000

Returns:

Type Description
NOctFrame

NOctFrame containing N-octave band spectrum

Source code in wandas/frames/mixins/channel_transform_mixin.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def noct_spectrum(
    self: T_Transform,
    fmin: float = 25,
    fmax: float = 20000,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
) -> "NOctFrame":
    """Calculate N-octave band spectrum.

    Args:
        fmin: Minimum center frequency (Hz). Default is 25 Hz.
        fmax: Maximum center frequency (Hz). Default is 20000 Hz.
        n: Band division (1: octave, 3: 1/3 octave). Default is 3.
        G: Reference gain (dB). Default is 10 dB.
        fr: Reference frequency (Hz). Default is 1000 Hz.

    Returns:
        NOctFrame containing N-octave band spectrum
    """
    from wandas.processing import NOctSpectrum, create_operation

    from ..noct import NOctFrame

    params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
    operation_name = "noct_spectrum"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("NOctSpectrum", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    return NOctFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        fmin=fmin,
        fmax=fmax,
        n=n,
        G=G,
        fr=fr,
        label=f"1/{n}Oct of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {
                "operation": "noct_spectrum",
                "params": params,
            },
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
stft(n_fft=2048, hop_length=None, win_length=None, window='hann')

Calculate Short-Time Fourier Transform.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'

Returns:

Type Description
SpectrogramFrame

SpectrogramFrame containing STFT results

Source code in wandas/frames/mixins/channel_transform_mixin.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def stft(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
) -> "SpectrogramFrame":
    """Calculate Short-Time Fourier Transform.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".

    Returns:
        SpectrogramFrame containing STFT results
    """
    from wandas.processing import STFT, create_operation

    from ..spectrogram import SpectrogramFrame

    # Set hop length and window length
    _hop_length = hop_length if hop_length is not None else n_fft // 4
    _win_length = win_length if win_length is not None else n_fft

    params = {
        "n_fft": n_fft,
        "hop_length": _hop_length,
        "win_length": _win_length,
        "window": window,
    }
    operation_name = "stft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("STFT", operation)

    # Apply processing to data
    spectrogram_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectrogramFrame with operation {operation_name} added to graph"  # noqa: E501
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new instance
    return SpectrogramFrame(
        data=spectrogram_data,
        sampling_rate=self.sampling_rate,
        n_fft=n_fft,
        hop_length=_hop_length,
        win_length=_win_length,
        window=window,
        label=f"stft({self.label})",
        metadata=self.metadata,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
coherence(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant')

Calculate magnitude squared coherence.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'

Returns:

Type Description
SpectralFrame

SpectralFrame containing magnitude squared coherence

Source code in wandas/frames/mixins/channel_transform_mixin.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def coherence(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
) -> "SpectralFrame":
    """Calculate magnitude squared coherence.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.

    Returns:
        SpectralFrame containing magnitude squared coherence
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.processing import Coherence, create_operation

    from ..spectral import SpectralFrame

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
    }
    operation_name = "coherence"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("Coherence", operation)

    # Apply processing to data
    coherence_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"$\\gamma_{{{in_ch.label}, {out_ch.label}}}$"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(
                in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
            )
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=coherence_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Coherence of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )
csd(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Calculate cross-spectral density matrix.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'
scaling str

Scaling method. Options: "spectrum", "density".

'spectrum'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

SpectralFrame containing cross-spectral density matrix

Source code in wandas/frames/mixins/channel_transform_mixin.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def csd(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
) -> "SpectralFrame":
    """Calculate cross-spectral density matrix.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.
        scaling: Scaling method. Options: "spectrum", "density".
        average: Method for averaging segments. Default is "mean".

    Returns:
        SpectralFrame containing cross-spectral density matrix
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import CSD, create_operation

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
        "scaling": scaling,
        "average": average,
    }
    operation_name = "csd"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("CSD", operation)

    # Apply processing to data
    csd_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"{operation_name}({in_ch.label}, {out_ch.label})"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(
                in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
            )
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=csd_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"$C_{{{in_ch.label}, {out_ch.label}}}$",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )
transfer_function(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Calculate transfer function matrix.

The transfer function represents the signal transfer characteristics between channels in the frequency domain and represents the input-output relationship of the system.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'
scaling str

Scaling method. Options: "spectrum", "density".

'spectrum'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

SpectralFrame containing transfer function matrix

Source code in wandas/frames/mixins/channel_transform_mixin.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
def transfer_function(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
) -> "SpectralFrame":
    """Calculate transfer function matrix.

    The transfer function represents the signal transfer characteristics between
    channels in the frequency domain and represents the input-output relationship
    of the system.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.
        scaling: Scaling method. Options: "spectrum", "density".
        average: Method for averaging segments. Default is "mean".

    Returns:
        SpectralFrame containing transfer function matrix
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import TransferFunction, create_operation

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
        "scaling": scaling,
        "average": average,
    }
    operation_name = "transfer_function"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("TransferFunction", operation)

    # Apply processing to data
    tf_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"$H_{{{in_ch.label}, {out_ch.label}}}$"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(
                in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
            )
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=tf_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Transfer function of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )
Modules
channel_collection_mixin

ChannelCollectionMixin: Common functionality for adding/removing channels in ChannelFrame

Attributes
T = TypeVar('T', bound='ChannelCollectionMixin') module-attribute
Classes
ChannelCollectionMixin
Source code in wandas/frames/mixins/channel_collection_mixin.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class ChannelCollectionMixin:
    def add_channel(
        self: T,
        data: np.ndarray[Any, Any] | da.Array | T,
        label: str | None = None,
        align: Literal["strict", "pad", "truncate"] = "strict",
        suffix_on_dup: str | None = None,
        inplace: bool = False,
        **kwargs: Any,
    ) -> T:
        """
        Add a channel
        Args:
            data: Channel to add (1ch ndarray/dask/ChannelFrame)
            label: Label for the added channel
            align: Behavior when lengths don't match
            suffix_on_dup: Suffix when label is duplicated
            inplace: True for self-modification
        Returns:
            New Frame or self
        Raises:
            ValueError, TypeError
        """
        raise NotImplementedError("add_channel() must be implemented in subclasses")

    def remove_channel(
        self: T,
        key: int | str,
        inplace: bool = False,
    ) -> T:
        """
        Remove a channel
        Args:
            key: Target to remove (index or label)
            inplace: True for self-modification
        Returns:
            New Frame or self
        Raises:
            ValueError, KeyError, IndexError
        """
        raise NotImplementedError("remove_channel() must be implemented in subclasses")
Functions
add_channel(data, label=None, align='strict', suffix_on_dup=None, inplace=False, **kwargs)

Add a channel Args: data: Channel to add (1ch ndarray/dask/ChannelFrame) label: Label for the added channel align: Behavior when lengths don't match suffix_on_dup: Suffix when label is duplicated inplace: True for self-modification Returns: New Frame or self Raises: ValueError, TypeError

Source code in wandas/frames/mixins/channel_collection_mixin.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def add_channel(
    self: T,
    data: np.ndarray[Any, Any] | da.Array | T,
    label: str | None = None,
    align: Literal["strict", "pad", "truncate"] = "strict",
    suffix_on_dup: str | None = None,
    inplace: bool = False,
    **kwargs: Any,
) -> T:
    """
    Add a channel
    Args:
        data: Channel to add (1ch ndarray/dask/ChannelFrame)
        label: Label for the added channel
        align: Behavior when lengths don't match
        suffix_on_dup: Suffix when label is duplicated
        inplace: True for self-modification
    Returns:
        New Frame or self
    Raises:
        ValueError, TypeError
    """
    raise NotImplementedError("add_channel() must be implemented in subclasses")
remove_channel(key, inplace=False)

Remove a channel Args: key: Target to remove (index or label) inplace: True for self-modification Returns: New Frame or self Raises: ValueError, KeyError, IndexError

Source code in wandas/frames/mixins/channel_collection_mixin.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def remove_channel(
    self: T,
    key: int | str,
    inplace: bool = False,
) -> T:
    """
    Remove a channel
    Args:
        key: Target to remove (index or label)
        inplace: True for self-modification
    Returns:
        New Frame or self
    Raises:
        ValueError, KeyError, IndexError
    """
    raise NotImplementedError("remove_channel() must be implemented in subclasses")
channel_processing_mixin

Module providing mixins related to signal processing.

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
ChannelProcessingMixin

Mixin that provides methods related to signal processing.

This mixin provides processing methods applied to audio signals and other time-series data, such as signal processing filters and transformation operations.

Source code in wandas/frames/mixins/channel_processing_mixin.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
class ChannelProcessingMixin:
    """Mixin that provides methods related to signal processing.

    This mixin provides processing methods applied to audio signals and
    other time-series data, such as signal processing filters and
    transformation operations.
    """

    def high_pass_filter(
        self: T_Processing, cutoff: float, order: int = 4
    ) -> T_Processing:
        """Apply a high-pass filter to the signal.

        Args:
            cutoff: Filter cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(
            f"Setting up highpass filter: cutoff={cutoff}, order={order} (lazy)"
        )
        result = self.apply_operation("highpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    def low_pass_filter(
        self: T_Processing, cutoff: float, order: int = 4
    ) -> T_Processing:
        """Apply a low-pass filter to the signal.

        Args:
            cutoff: Filter cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(
            f"Setting up lowpass filter: cutoff={cutoff}, order={order} (lazy)"
        )
        result = self.apply_operation("lowpass_filter", cutoff=cutoff, order=order)
        return cast(T_Processing, result)

    def band_pass_filter(
        self: T_Processing, low_cutoff: float, high_cutoff: float, order: int = 4
    ) -> T_Processing:
        """Apply a band-pass filter to the signal.

        Args:
            low_cutoff: Lower cutoff frequency (Hz)
            high_cutoff: Higher cutoff frequency (Hz)
            order: Filter order. Default is 4.

        Returns:
            New ChannelFrame after filter application
        """
        logger.debug(
            f"Setting up bandpass filter: low_cutoff={low_cutoff}, "
            f"high_cutoff={high_cutoff}, order={order} (lazy)"
        )
        result = self.apply_operation(
            "bandpass_filter",
            low_cutoff=low_cutoff,
            high_cutoff=high_cutoff,
            order=order,
        )
        return cast(T_Processing, result)

    def normalize(
        self: T_Processing,
        norm: float | None = float("inf"),
        axis: int | None = -1,
        threshold: float | None = None,
        fill: bool | None = None,
    ) -> T_Processing:
        """Normalize signal levels using librosa.util.normalize.

        This method normalizes the signal amplitude according to the specified norm.

        Args:
            norm: Norm type. Default is np.inf (maximum absolute value normalization).
                Supported values:
                - np.inf: Maximum absolute value normalization
                - -np.inf: Minimum absolute value normalization
                - 0: Peak normalization
                - float: Lp norm
                - None: No normalization
            axis: Axis along which to normalize. Default is -1 (time axis).
                - -1: Normalize along time axis (each channel independently)
                - None: Global normalization across all axes
                - int: Normalize along specified axis
            threshold: Threshold below which values are considered zero.
                If None, no threshold is applied.
            fill: Value to fill when the norm is zero.
                If None, the zero vector remains zero.

        Returns:
            New ChannelFrame containing the normalized signal

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> # Normalize to maximum absolute value of 1.0 (per channel)
            >>> normalized = signal.normalize()
            >>> # Global normalization across all channels
            >>> normalized_global = signal.normalize(axis=None)
            >>> # L2 normalization
            >>> normalized_l2 = signal.normalize(norm=2)
        """
        logger.debug(
            f"Setting up normalize: norm={norm}, axis={axis}, "
            f"threshold={threshold}, fill={fill} (lazy)"
        )
        result = self.apply_operation(
            "normalize", norm=norm, axis=axis, threshold=threshold, fill=fill
        )
        return cast(T_Processing, result)

    def remove_dc(self: T_Processing) -> T_Processing:
        """Remove DC component (DC offset) from the signal.

        This method removes the DC (direct current) component by subtracting
        the mean value from each channel. This is equivalent to centering the
        signal around zero.

        Returns:
            New ChannelFrame with DC component removed

        Examples:
            >>> import wandas as wd
            >>> import numpy as np
            >>> # Create signal with DC offset
            >>> signal = wd.read_wav("audio.wav")
            >>> signal_with_dc = signal + 2.0  # Add DC offset
            >>> # Remove DC offset
            >>> signal_clean = signal_with_dc.remove_dc()
            >>> # Verify DC removal
            >>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)

        Notes:
            - This operation is performed per channel
            - Equivalent to applying a high-pass filter with very low cutoff
            - Useful for removing sensor drift or measurement offset
        """
        logger.debug("Setting up DC removal (lazy)")
        result = self.apply_operation("remove_dc")
        return cast(T_Processing, result)

    def a_weighting(self: T_Processing) -> T_Processing:
        """Apply A-weighting filter to the signal.

        A-weighting adjusts the frequency response to approximate human
        auditory perception, according to the IEC 61672-1:2013 standard.

        Returns:
            New ChannelFrame containing the A-weighted signal
        """
        result = self.apply_operation("a_weighting")
        return cast(T_Processing, result)

    def abs(self: T_Processing) -> T_Processing:
        """Compute the absolute value of the signal.

        Returns:
            New ChannelFrame containing the absolute values
        """
        result = self.apply_operation("abs")
        return cast(T_Processing, result)

    def power(self: T_Processing, exponent: float = 2.0) -> T_Processing:
        """Compute the power of the signal.

        Args:
            exponent: Exponent to raise the signal to. Default is 2.0.

        Returns:
            New ChannelFrame containing the powered signal
        """
        result = self.apply_operation("power", exponent=exponent)
        return cast(T_Processing, result)

    def _reduce_channels(self: T_Processing, op: str) -> T_Processing:
        """Helper to reduce all channels with the given operation ('sum' or 'mean')."""
        if op == "sum":
            reduced_data = self._data.sum(axis=0, keepdims=True)
            label = "sum"
        elif op == "mean":
            reduced_data = self._data.mean(axis=0, keepdims=True)
            label = "mean"
        else:
            raise ValueError(f"Unsupported reduction operation: {op}")

        units = [ch.unit for ch in self._channel_metadata]
        if all(u == units[0] for u in units):
            reduced_unit = units[0]
        else:
            reduced_unit = ""

        reduced_extra = {"source_extras": [ch.extra for ch in self._channel_metadata]}
        new_channel_metadata = [
            ChannelMetadata(
                label=label,
                unit=reduced_unit,
                extra=reduced_extra,
            )
        ]
        new_history = (
            self.operation_history.copy() if hasattr(self, "operation_history") else []
        )
        new_history.append({"operation": op})
        new_metadata = self.metadata.copy() if hasattr(self, "metadata") else {}
        result = self._create_new_instance(
            data=reduced_data,
            metadata=new_metadata,
            operation_history=new_history,
            channel_metadata=new_channel_metadata,
        )
        return result

    def sum(self: T_Processing) -> T_Processing:
        """Sum all channels.

        Returns:
            A new ChannelFrame with summed signal.
        """
        return cast(T_Processing, cast(Any, self)._reduce_channels("sum"))

    def mean(self: T_Processing) -> T_Processing:
        """Average all channels.

        Returns:
            A new ChannelFrame with averaged signal.
        """
        return cast(T_Processing, cast(Any, self)._reduce_channels("mean"))

    def trim(
        self: T_Processing,
        start: float = 0,
        end: float | None = None,
    ) -> T_Processing:
        """Trim the signal to the specified time range.

        Args:
            start: Start time (seconds)
            end: End time (seconds)

        Returns:
            New ChannelFrame containing the trimmed signal

        Raises:
            ValueError: If end time is earlier than start time
        """
        if end is None:
            end = self.duration
        if start > end:
            raise ValueError("start must be less than end")
        result = self.apply_operation("trim", start=start, end=end)
        return cast(T_Processing, result)

    def fix_length(
        self: T_Processing,
        length: int | None = None,
        duration: float | None = None,
    ) -> T_Processing:
        """Adjust the signal to the specified length.

        Args:
            duration: Signal length in seconds
            length: Signal length in samples

        Returns:
            New ChannelFrame containing the adjusted signal
        """

        result = self.apply_operation("fix_length", length=length, duration=duration)
        return cast(T_Processing, result)

    def rms_trend(
        self: T_Processing,
        frame_length: int = 2048,
        hop_length: int = 512,
        dB: bool = False,  # noqa: N803
        Aw: bool = False,  # noqa: N803
    ) -> T_Processing:
        """Compute the RMS trend of the signal.

        This method calculates the root mean square value over a sliding window.

        Args:
            frame_length: Size of the sliding window in samples. Default is 2048.
            hop_length: Hop length between windows in samples. Default is 512.
            dB: Whether to return RMS values in decibels. Default is False.
            Aw: Whether to apply A-weighting. Default is False.

        Returns:
            New ChannelFrame containing the RMS trend
        """
        # Access _channel_metadata to retrieve reference values
        frame = cast(ProcessingFrameProtocol, self)

        # Ensure _channel_metadata exists before referencing
        ref_values = []
        if hasattr(frame, "_channel_metadata") and frame._channel_metadata:
            ref_values = [ch.ref for ch in frame._channel_metadata]

        result = self.apply_operation(
            "rms_trend",
            frame_length=frame_length,
            hop_length=hop_length,
            ref=ref_values,
            dB=dB,
            Aw=Aw,
        )

        # Sampling rate update is handled by the Operation class
        return cast(T_Processing, result)

    def channel_difference(
        self: T_Processing, other_channel: int | str = 0
    ) -> T_Processing:
        """Compute the difference between channels.

        Args:
            other_channel: Index or label of the reference channel. Default is 0.

        Returns:
            New ChannelFrame containing the channel difference
        """
        # label2index is a method of BaseFrame
        if isinstance(other_channel, str):
            if hasattr(self, "label2index"):
                other_channel = self.label2index(other_channel)

        result = self.apply_operation("channel_difference", other_channel=other_channel)
        return cast(T_Processing, result)

    def resampling(
        self: T_Processing,
        target_sr: float,
        **kwargs: Any,
    ) -> T_Processing:
        """Resample audio data.

        Args:
            target_sr: Target sampling rate (Hz)
            **kwargs: Additional resampling parameters

        Returns:
            Resampled ChannelFrame
        """
        return cast(
            T_Processing,
            self.apply_operation(
                "resampling",
                target_sr=target_sr,
                **kwargs,
            ),
        )

    def hpss_harmonic(
        self: T_Processing,
        kernel_size: Union[
            "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
        ] = 31,
        power: float = 2,
        margin: Union[
            "_FloatLike_co",
            tuple["_FloatLike_co", "_FloatLike_co"],
            list["_FloatLike_co"],
        ] = 1,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: "_WindowSpec" = "hann",
        center: bool = True,
        pad_mode: "_PadModeSTFT" = "constant",
    ) -> T_Processing:
        """
        Extract harmonic components using HPSS
         (Harmonic-Percussive Source Separation).

        This method separates the harmonic (tonal) components from the signal.

        Args:
            kernel_size: Median filter size for HPSS.
            power: Exponent for the Weiner filter used in HPSS.
            margin: Margin size for the separation.
            n_fft: Size of FFT window.
            hop_length: Hop length for STFT.
            win_length: Window length for STFT.
            window: Window type for STFT.
            center: If True, center the frames.
            pad_mode: Padding mode for STFT.

        Returns:
            A new ChannelFrame containing the harmonic components.
        """
        result = self.apply_operation(
            "hpss_harmonic",
            kernel_size=kernel_size,
            power=power,
            margin=margin,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            center=center,
            pad_mode=pad_mode,
        )
        return cast(T_Processing, result)

    def hpss_percussive(
        self: T_Processing,
        kernel_size: Union[
            "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
        ] = 31,
        power: float = 2,
        margin: Union[
            "_FloatLike_co",
            tuple["_FloatLike_co", "_FloatLike_co"],
            list["_FloatLike_co"],
        ] = 1,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: "_WindowSpec" = "hann",
        center: bool = True,
        pad_mode: "_PadModeSTFT" = "constant",
    ) -> T_Processing:
        """
        Extract percussive components using HPSS
        (Harmonic-Percussive Source Separation).

        This method separates the percussive (tonal) components from the signal.

        Args:
            kernel_size: Median filter size for HPSS.
            power: Exponent for the Weiner filter used in HPSS.
            margin: Margin size for the separation.

        Returns:
            A new ChannelFrame containing the harmonic components.
        """
        result = self.apply_operation(
            "hpss_percussive",
            kernel_size=kernel_size,
            power=power,
            margin=margin,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            center=center,
            pad_mode=pad_mode,
        )
        return cast(T_Processing, result)

    def loudness_zwtv(self: T_Processing, field_type: str = "free") -> T_Processing:
        """
        Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

        This method computes the loudness of non-stationary signals according to
        the Zwicker method, as specified in ISO 532-1:2017. The loudness is
        calculated in sones, where a doubling of sones corresponds to a doubling
        of perceived loudness.

        Args:
            field_type: Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)
                Default is 'free'.

        Returns:
            New ChannelFrame containing time-varying loudness values in sones.
            Each channel is processed independently.
            The output sampling rate is adjusted based on the loudness
            calculation time resolution (typically ~500 Hz for 2ms steps).

        Raises:
            ValueError: If field_type is not 'free' or 'diffuse'

        Examples:
            Calculate loudness for a signal:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> loudness = signal.loudness_zwtv(field_type="free")
            >>> loudness.plot(title="Time-varying Loudness")

            Compare free field and diffuse field:
            >>> loudness_free = signal.loudness_zwtv(field_type="free")
            >>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")

        Notes:
            - The output contains time-varying loudness values in sones
            - Typical loudness: 1 sone ≈ 40 phon (loudness level)
            - The time resolution is approximately 2ms (determined by the algorithm)
            - For multi-channel signals, loudness is calculated per channel
            - The output sampling rate is updated to reflect the time resolution

            **Time axis convention:**
            The time axis in the returned frame represents the start time of
            each 2ms analysis step. This differs slightly from the MoSQITo
            library, which uses the center time of each step. For example:

            - wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
            - MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

            The difference is very small (~1ms) and does not affect the loudness
            values themselves. This design choice ensures consistency with
            wandas's time axis convention across all frame types.

        References:
            ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
            Part 1: Zwicker method"
        """
        result = self.apply_operation("loudness_zwtv", field_type=field_type)

        # Sampling rate update is handled by the Operation class
        return cast(T_Processing, result)

    def loudness_zwst(self: T_Processing, field_type: str = "free") -> "NDArrayReal":
        """
        Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

        This method computes the loudness of stationary (steady) signals according to
        the Zwicker method, as specified in ISO 532-1:2017. The loudness is
        calculated in sones, where a doubling of sones corresponds to a doubling
        of perceived loudness.

        This method is suitable for analyzing steady sounds such as fan noise,
        constant machinery sounds, or other stationary signals.

        Args:
            field_type: Type of sound field. Options:
                - 'free': Free field (sound from a specific direction)
                - 'diffuse': Diffuse field (sound from all directions)
                Default is 'free'.

        Returns:
            Loudness values in sones, one per channel. Shape: (n_channels,)

        Raises:
            ValueError: If field_type is not 'free' or 'diffuse'

        Examples:
            Calculate steady-state loudness for a fan noise:
            >>> import wandas as wd
            >>> signal = wd.read_wav("fan_noise.wav")
            >>> loudness = signal.loudness_zwst(field_type="free")
            >>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
            >>> print(f"Mean loudness: {loudness.mean():.2f} sones")

            Compare free field and diffuse field:
            >>> loudness_free = signal.loudness_zwst(field_type="free")
            >>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
            >>> print(f"Free field: {loudness_free[0]:.2f} sones")
            >>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")

        Notes:
            - Returns a 1D array with one loudness value per channel
            - Typical loudness: 1 sone ≈ 40 phon (loudness level)
            - For multi-channel signals, loudness is calculated independently
              per channel
            - This method is designed for stationary signals (constant sounds)
            - For time-varying signals, use loudness_zwtv() instead
            - Similar to the rms property, returns NDArrayReal for consistency

        References:
            ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
            Part 1: Zwicker method"
        """
        # Treat self as a ProcessingFrameProtocol so mypy understands
        # where sampling_rate and data come from.
        from wandas.processing.psychoacoustic import LoudnessZwst
        from wandas.utils.types import NDArrayReal

        # Create operation instance
        operation = LoudnessZwst(self.sampling_rate, field_type=field_type)

        # Get data (triggers computation if lazy)
        data = self.data

        # Ensure data is 2D (n_channels, n_samples)
        if data.ndim == 1:
            data = data.reshape(1, -1)
        # Process the array using the public API and materialize to NumPy
        result = operation.process_array(data).compute()

        # Squeeze to get 1D array (n_channels,)
        loudness_values: NDArrayReal = result.squeeze()

        # Ensure it's 1D even for single channel
        if loudness_values.ndim == 0:
            loudness_values = loudness_values.reshape(1)

        return loudness_values

    def roughness_dw(self: T_Processing, overlap: float = 0.5) -> T_Processing:
        """Calculate time-varying roughness using Daniel and Weber method.

        Roughness is a psychoacoustic metric that quantifies the perceived
        harshness or roughness of a sound, measured in asper. This method
        implements the Daniel & Weber (1997) standard calculation.

        The calculation follows the standard formula:
        R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

        Args:
            overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
                - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
                - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
                Default is 0.5.

        Returns:
            New ChannelFrame containing time-varying roughness values in asper.
            The output sampling rate depends on the overlap parameter.

        Raises:
            ValueError: If overlap is not in the range [0.0, 1.0]

        Examples:
            Calculate roughness for a motor noise:
            >>> import wandas as wd
            >>> signal = wd.read_wav("motor_noise.wav")
            >>> roughness = signal.roughness_dw(overlap=0.5)
            >>> roughness.plot(ylabel="Roughness [asper]")

            Analyze roughness statistics:
            >>> mean_roughness = roughness.data.mean()
            >>> max_roughness = roughness.data.max()
            >>> print(f"Mean: {mean_roughness:.2f} asper")
            >>> print(f"Max: {max_roughness:.2f} asper")

            Compare before and after modification:
            >>> before = wd.read_wav("motor_before.wav").roughness_dw()
            >>> after = wd.read_wav("motor_after.wav").roughness_dw()
            >>> improvement = before.data.mean() - after.data.mean()
            >>> print(f"Roughness reduction: {improvement:.2f} asper")

        Notes:
            - Returns a ChannelFrame with time-varying roughness values
            - Typical roughness values: 0-2 asper for most sounds
            - Higher values indicate rougher, harsher sounds
            - For multi-channel signals, roughness is calculated independently
              per channel
            - This is the standard-compliant total roughness (R)
            - For detailed Bark-band analysis, use roughness_dw_spec() instead

            **Time axis convention:**
            The time axis in the returned frame represents the start time of
            each 200ms analysis window. This differs from the MoSQITo library,
            which uses the center time of each window. For example:

            - wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
            - MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

            The difference is constant (half the window duration = 100ms) and
            does not affect the roughness values themselves. This design choice
            ensures consistency with wandas's time axis convention across all
            frame types.

        References:
            Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
            Implementation of an optimized model." Acustica, 83, 113-123.
        """
        logger.debug(f"Applying roughness_dw operation with overlap={overlap} (lazy)")
        result = self.apply_operation("roughness_dw", overlap=overlap)
        return cast(T_Processing, result)

    def roughness_dw_spec(self: T_Processing, overlap: float = 0.5) -> "RoughnessFrame":
        """Calculate specific roughness with Bark-band frequency information.

        This method returns detailed roughness analysis data organized by
        Bark frequency bands over time, allowing for frequency-specific
        roughness analysis. It uses the Daniel & Weber (1997) method.

        The relationship between total roughness and specific roughness:
        R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

        Args:
            overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
                - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
                - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
                Default is 0.5.

        Returns:
            RoughnessFrame containing:
                - data: Specific roughness by Bark band, shape (47, n_time)
                        for mono or (n_channels, 47, n_time) for multi-channel
                - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5)
                - time: Time axis for each analysis frame
                - overlap: Overlap coefficient used
                - plot(): Method for Bark-Time heatmap visualization

        Raises:
            ValueError: If overlap is not in the range [0.0, 1.0]

        Examples:
            Analyze frequency-specific roughness:
            >>> import wandas as wd
            >>> import numpy as np
            >>> signal = wd.read_wav("motor.wav")
            >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
            >>>
            >>> # Plot Bark-Time heatmap
            >>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
            >>>
            >>> # Find dominant Bark band
            >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
            >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
            >>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
            >>>
            >>> # Extract specific Bark band time series
            >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
            >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
            >>>
            >>> # Verify standard formula
            >>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
            >>> # This should match signal.roughness_dw(overlap=0.5).data

        Notes:
            - Returns a RoughnessFrame (not ChannelFrame)
            - Contains 47 Bark bands from 0.5 to 23.5 Bark
            - Each Bark band corresponds to a critical band of hearing
            - Useful for identifying which frequencies contribute most to roughness
            - The specific roughness can be integrated to obtain total roughness
            - For simple time-series analysis, use roughness_dw() instead

            **Time axis convention:**
            The time axis represents the start time of each 200ms analysis
            window, consistent with roughness_dw() and other wandas methods.

        References:
            Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
            Implementation of an optimized model." Acustica, 83, 113-123.
        """

        params = {"overlap": overlap}
        operation_name = "roughness_dw_spec"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance via factory
        operation = create_operation(operation_name, self.sampling_rate, **params)

        # Apply processing lazily to self._data (Dask)
        r_spec_dask = operation.process(self._data)

        # Get metadata updates (sampling rate, bark_axis)
        metadata_updates = operation.get_metadata_updates()

        # Build metadata and history
        new_metadata = {**self.metadata, **params}
        new_history = [
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ]

        # Extract bark_axis with proper type handling
        bark_axis_value = metadata_updates.get("bark_axis")
        if bark_axis_value is None:
            raise ValueError("Operation did not provide bark_axis in metadata")

        # Create RoughnessFrame. operation.get_metadata_updates() should provide
        # sampling_rate and bark_axis
        roughness_frame = RoughnessFrame(
            data=r_spec_dask,
            sampling_rate=metadata_updates.get("sampling_rate", self.sampling_rate),
            bark_axis=bark_axis_value,
            overlap=overlap,
            label=f"{self.label}_roughness_spec" if self.label else "roughness_spec",
            metadata=new_metadata,
            operation_history=new_history,
            channel_metadata=self._channel_metadata,
            previous=cast("BaseFrame[NDArrayReal]", self),
        )

        logger.debug(
            "Created RoughnessFrame via operation %s, shape=%s, sampling_rate=%.2f Hz",
            operation_name,
            r_spec_dask.shape,
            roughness_frame.sampling_rate,
        )

        return roughness_frame

    def fade(self: T_Processing, fade_ms: float = 50) -> T_Processing:
        """Apply symmetric fade-in and fade-out to the signal using Tukey window.

        This method applies a symmetric fade-in and fade-out envelope to the signal
        using a Tukey (tapered cosine) window. The fade duration is the same for
        both the beginning and end of the signal.

        Args:
            fade_ms: Fade duration in milliseconds for each end of the signal.
                The total fade duration is 2 * fade_ms. Default is 50 ms.
                Must be positive and less than half the signal duration.

        Returns:
            New ChannelFrame containing the faded signal

        Raises:
            ValueError: If fade_ms is negative or too long for the signal

        Examples:
            >>> import wandas as wd
            >>> signal = wd.read_wav("audio.wav")
            >>> # Apply 10ms fade-in and fade-out
            >>> faded = signal.fade(fade_ms=10.0)
            >>> # Apply very short fade (almost no effect)
            >>> faded_short = signal.fade(fade_ms=0.1)

        Notes:
            - Uses SciPy's Tukey window for smooth fade transitions
            - Fade is applied symmetrically to both ends of the signal
            - The Tukey window alpha parameter is computed automatically
              based on the fade duration and signal length
            - For multi-channel signals, the same fade envelope is applied
              to all channels
            - Lazy evaluation is preserved - computation occurs only when needed
        """
        logger.debug(f"Setting up fade: fade_ms={fade_ms} (lazy)")
        result = self.apply_operation("fade", fade_ms=fade_ms)
        return cast(T_Processing, result)
Functions
high_pass_filter(cutoff, order=4)

Apply a high-pass filter to the signal.

Parameters:

Name Type Description Default
cutoff float

Filter cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def high_pass_filter(
    self: T_Processing, cutoff: float, order: int = 4
) -> T_Processing:
    """Apply a high-pass filter to the signal.

    Args:
        cutoff: Filter cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(
        f"Setting up highpass filter: cutoff={cutoff}, order={order} (lazy)"
    )
    result = self.apply_operation("highpass_filter", cutoff=cutoff, order=order)
    return cast(T_Processing, result)
low_pass_filter(cutoff, order=4)

Apply a low-pass filter to the signal.

Parameters:

Name Type Description Default
cutoff float

Filter cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def low_pass_filter(
    self: T_Processing, cutoff: float, order: int = 4
) -> T_Processing:
    """Apply a low-pass filter to the signal.

    Args:
        cutoff: Filter cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(
        f"Setting up lowpass filter: cutoff={cutoff}, order={order} (lazy)"
    )
    result = self.apply_operation("lowpass_filter", cutoff=cutoff, order=order)
    return cast(T_Processing, result)
band_pass_filter(low_cutoff, high_cutoff, order=4)

Apply a band-pass filter to the signal.

Parameters:

Name Type Description Default
low_cutoff float

Lower cutoff frequency (Hz)

required
high_cutoff float

Higher cutoff frequency (Hz)

required
order int

Filter order. Default is 4.

4

Returns:

Type Description
T_Processing

New ChannelFrame after filter application

Source code in wandas/frames/mixins/channel_processing_mixin.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def band_pass_filter(
    self: T_Processing, low_cutoff: float, high_cutoff: float, order: int = 4
) -> T_Processing:
    """Apply a band-pass filter to the signal.

    Args:
        low_cutoff: Lower cutoff frequency (Hz)
        high_cutoff: Higher cutoff frequency (Hz)
        order: Filter order. Default is 4.

    Returns:
        New ChannelFrame after filter application
    """
    logger.debug(
        f"Setting up bandpass filter: low_cutoff={low_cutoff}, "
        f"high_cutoff={high_cutoff}, order={order} (lazy)"
    )
    result = self.apply_operation(
        "bandpass_filter",
        low_cutoff=low_cutoff,
        high_cutoff=high_cutoff,
        order=order,
    )
    return cast(T_Processing, result)
normalize(norm=float('inf'), axis=-1, threshold=None, fill=None)

Normalize signal levels using librosa.util.normalize.

This method normalizes the signal amplitude according to the specified norm.

Parameters:

Name Type Description Default
norm float | None

Norm type. Default is np.inf (maximum absolute value normalization). Supported values: - np.inf: Maximum absolute value normalization - -np.inf: Minimum absolute value normalization - 0: Peak normalization - float: Lp norm - None: No normalization

float('inf')
axis int | None

Axis along which to normalize. Default is -1 (time axis). - -1: Normalize along time axis (each channel independently) - None: Global normalization across all axes - int: Normalize along specified axis

-1
threshold float | None

Threshold below which values are considered zero. If None, no threshold is applied.

None
fill bool | None

Value to fill when the norm is zero. If None, the zero vector remains zero.

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the normalized signal

Examples:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> # Normalize to maximum absolute value of 1.0 (per channel)
>>> normalized = signal.normalize()
>>> # Global normalization across all channels
>>> normalized_global = signal.normalize(axis=None)
>>> # L2 normalization
>>> normalized_l2 = signal.normalize(norm=2)
Source code in wandas/frames/mixins/channel_processing_mixin.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def normalize(
    self: T_Processing,
    norm: float | None = float("inf"),
    axis: int | None = -1,
    threshold: float | None = None,
    fill: bool | None = None,
) -> T_Processing:
    """Normalize signal levels using librosa.util.normalize.

    This method normalizes the signal amplitude according to the specified norm.

    Args:
        norm: Norm type. Default is np.inf (maximum absolute value normalization).
            Supported values:
            - np.inf: Maximum absolute value normalization
            - -np.inf: Minimum absolute value normalization
            - 0: Peak normalization
            - float: Lp norm
            - None: No normalization
        axis: Axis along which to normalize. Default is -1 (time axis).
            - -1: Normalize along time axis (each channel independently)
            - None: Global normalization across all axes
            - int: Normalize along specified axis
        threshold: Threshold below which values are considered zero.
            If None, no threshold is applied.
        fill: Value to fill when the norm is zero.
            If None, the zero vector remains zero.

    Returns:
        New ChannelFrame containing the normalized signal

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> # Normalize to maximum absolute value of 1.0 (per channel)
        >>> normalized = signal.normalize()
        >>> # Global normalization across all channels
        >>> normalized_global = signal.normalize(axis=None)
        >>> # L2 normalization
        >>> normalized_l2 = signal.normalize(norm=2)
    """
    logger.debug(
        f"Setting up normalize: norm={norm}, axis={axis}, "
        f"threshold={threshold}, fill={fill} (lazy)"
    )
    result = self.apply_operation(
        "normalize", norm=norm, axis=axis, threshold=threshold, fill=fill
    )
    return cast(T_Processing, result)
remove_dc()

Remove DC component (DC offset) from the signal.

This method removes the DC (direct current) component by subtracting the mean value from each channel. This is equivalent to centering the signal around zero.

Returns:

Type Description
T_Processing

New ChannelFrame with DC component removed

Examples:

>>> import wandas as wd
>>> import numpy as np
>>> # Create signal with DC offset
>>> signal = wd.read_wav("audio.wav")
>>> signal_with_dc = signal + 2.0  # Add DC offset
>>> # Remove DC offset
>>> signal_clean = signal_with_dc.remove_dc()
>>> # Verify DC removal
>>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)
Notes
  • This operation is performed per channel
  • Equivalent to applying a high-pass filter with very low cutoff
  • Useful for removing sensor drift or measurement offset
Source code in wandas/frames/mixins/channel_processing_mixin.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def remove_dc(self: T_Processing) -> T_Processing:
    """Remove DC component (DC offset) from the signal.

    This method removes the DC (direct current) component by subtracting
    the mean value from each channel. This is equivalent to centering the
    signal around zero.

    Returns:
        New ChannelFrame with DC component removed

    Examples:
        >>> import wandas as wd
        >>> import numpy as np
        >>> # Create signal with DC offset
        >>> signal = wd.read_wav("audio.wav")
        >>> signal_with_dc = signal + 2.0  # Add DC offset
        >>> # Remove DC offset
        >>> signal_clean = signal_with_dc.remove_dc()
        >>> # Verify DC removal
        >>> assert np.allclose(signal_clean.data.mean(axis=1), 0, atol=1e-10)

    Notes:
        - This operation is performed per channel
        - Equivalent to applying a high-pass filter with very low cutoff
        - Useful for removing sensor drift or measurement offset
    """
    logger.debug("Setting up DC removal (lazy)")
    result = self.apply_operation("remove_dc")
    return cast(T_Processing, result)
a_weighting()

Apply A-weighting filter to the signal.

A-weighting adjusts the frequency response to approximate human auditory perception, according to the IEC 61672-1:2013 standard.

Returns:

Type Description
T_Processing

New ChannelFrame containing the A-weighted signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
174
175
176
177
178
179
180
181
182
183
184
def a_weighting(self: T_Processing) -> T_Processing:
    """Apply A-weighting filter to the signal.

    A-weighting adjusts the frequency response to approximate human
    auditory perception, according to the IEC 61672-1:2013 standard.

    Returns:
        New ChannelFrame containing the A-weighted signal
    """
    result = self.apply_operation("a_weighting")
    return cast(T_Processing, result)
abs()

Compute the absolute value of the signal.

Returns:

Type Description
T_Processing

New ChannelFrame containing the absolute values

Source code in wandas/frames/mixins/channel_processing_mixin.py
186
187
188
189
190
191
192
193
def abs(self: T_Processing) -> T_Processing:
    """Compute the absolute value of the signal.

    Returns:
        New ChannelFrame containing the absolute values
    """
    result = self.apply_operation("abs")
    return cast(T_Processing, result)
power(exponent=2.0)

Compute the power of the signal.

Parameters:

Name Type Description Default
exponent float

Exponent to raise the signal to. Default is 2.0.

2.0

Returns:

Type Description
T_Processing

New ChannelFrame containing the powered signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
195
196
197
198
199
200
201
202
203
204
205
def power(self: T_Processing, exponent: float = 2.0) -> T_Processing:
    """Compute the power of the signal.

    Args:
        exponent: Exponent to raise the signal to. Default is 2.0.

    Returns:
        New ChannelFrame containing the powered signal
    """
    result = self.apply_operation("power", exponent=exponent)
    return cast(T_Processing, result)
sum()

Sum all channels.

Returns:

Type Description
T_Processing

A new ChannelFrame with summed signal.

Source code in wandas/frames/mixins/channel_processing_mixin.py
245
246
247
248
249
250
251
def sum(self: T_Processing) -> T_Processing:
    """Sum all channels.

    Returns:
        A new ChannelFrame with summed signal.
    """
    return cast(T_Processing, cast(Any, self)._reduce_channels("sum"))
mean()

Average all channels.

Returns:

Type Description
T_Processing

A new ChannelFrame with averaged signal.

Source code in wandas/frames/mixins/channel_processing_mixin.py
253
254
255
256
257
258
259
def mean(self: T_Processing) -> T_Processing:
    """Average all channels.

    Returns:
        A new ChannelFrame with averaged signal.
    """
    return cast(T_Processing, cast(Any, self)._reduce_channels("mean"))
trim(start=0, end=None)

Trim the signal to the specified time range.

Parameters:

Name Type Description Default
start float

Start time (seconds)

0
end float | None

End time (seconds)

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the trimmed signal

Raises:

Type Description
ValueError

If end time is earlier than start time

Source code in wandas/frames/mixins/channel_processing_mixin.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def trim(
    self: T_Processing,
    start: float = 0,
    end: float | None = None,
) -> T_Processing:
    """Trim the signal to the specified time range.

    Args:
        start: Start time (seconds)
        end: End time (seconds)

    Returns:
        New ChannelFrame containing the trimmed signal

    Raises:
        ValueError: If end time is earlier than start time
    """
    if end is None:
        end = self.duration
    if start > end:
        raise ValueError("start must be less than end")
    result = self.apply_operation("trim", start=start, end=end)
    return cast(T_Processing, result)
fix_length(length=None, duration=None)

Adjust the signal to the specified length.

Parameters:

Name Type Description Default
duration float | None

Signal length in seconds

None
length int | None

Signal length in samples

None

Returns:

Type Description
T_Processing

New ChannelFrame containing the adjusted signal

Source code in wandas/frames/mixins/channel_processing_mixin.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def fix_length(
    self: T_Processing,
    length: int | None = None,
    duration: float | None = None,
) -> T_Processing:
    """Adjust the signal to the specified length.

    Args:
        duration: Signal length in seconds
        length: Signal length in samples

    Returns:
        New ChannelFrame containing the adjusted signal
    """

    result = self.apply_operation("fix_length", length=length, duration=duration)
    return cast(T_Processing, result)
rms_trend(frame_length=2048, hop_length=512, dB=False, Aw=False)

Compute the RMS trend of the signal.

This method calculates the root mean square value over a sliding window.

Parameters:

Name Type Description Default
frame_length int

Size of the sliding window in samples. Default is 2048.

2048
hop_length int

Hop length between windows in samples. Default is 512.

512
dB bool

Whether to return RMS values in decibels. Default is False.

False
Aw bool

Whether to apply A-weighting. Default is False.

False

Returns:

Type Description
T_Processing

New ChannelFrame containing the RMS trend

Source code in wandas/frames/mixins/channel_processing_mixin.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def rms_trend(
    self: T_Processing,
    frame_length: int = 2048,
    hop_length: int = 512,
    dB: bool = False,  # noqa: N803
    Aw: bool = False,  # noqa: N803
) -> T_Processing:
    """Compute the RMS trend of the signal.

    This method calculates the root mean square value over a sliding window.

    Args:
        frame_length: Size of the sliding window in samples. Default is 2048.
        hop_length: Hop length between windows in samples. Default is 512.
        dB: Whether to return RMS values in decibels. Default is False.
        Aw: Whether to apply A-weighting. Default is False.

    Returns:
        New ChannelFrame containing the RMS trend
    """
    # Access _channel_metadata to retrieve reference values
    frame = cast(ProcessingFrameProtocol, self)

    # Ensure _channel_metadata exists before referencing
    ref_values = []
    if hasattr(frame, "_channel_metadata") and frame._channel_metadata:
        ref_values = [ch.ref for ch in frame._channel_metadata]

    result = self.apply_operation(
        "rms_trend",
        frame_length=frame_length,
        hop_length=hop_length,
        ref=ref_values,
        dB=dB,
        Aw=Aw,
    )

    # Sampling rate update is handled by the Operation class
    return cast(T_Processing, result)
channel_difference(other_channel=0)

Compute the difference between channels.

Parameters:

Name Type Description Default
other_channel int | str

Index or label of the reference channel. Default is 0.

0

Returns:

Type Description
T_Processing

New ChannelFrame containing the channel difference

Source code in wandas/frames/mixins/channel_processing_mixin.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
def channel_difference(
    self: T_Processing, other_channel: int | str = 0
) -> T_Processing:
    """Compute the difference between channels.

    Args:
        other_channel: Index or label of the reference channel. Default is 0.

    Returns:
        New ChannelFrame containing the channel difference
    """
    # label2index is a method of BaseFrame
    if isinstance(other_channel, str):
        if hasattr(self, "label2index"):
            other_channel = self.label2index(other_channel)

    result = self.apply_operation("channel_difference", other_channel=other_channel)
    return cast(T_Processing, result)
resampling(target_sr, **kwargs)

Resample audio data.

Parameters:

Name Type Description Default
target_sr float

Target sampling rate (Hz)

required
**kwargs Any

Additional resampling parameters

{}

Returns:

Type Description
T_Processing

Resampled ChannelFrame

Source code in wandas/frames/mixins/channel_processing_mixin.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def resampling(
    self: T_Processing,
    target_sr: float,
    **kwargs: Any,
) -> T_Processing:
    """Resample audio data.

    Args:
        target_sr: Target sampling rate (Hz)
        **kwargs: Additional resampling parameters

    Returns:
        Resampled ChannelFrame
    """
    return cast(
        T_Processing,
        self.apply_operation(
            "resampling",
            target_sr=target_sr,
            **kwargs,
        ),
    )
hpss_harmonic(kernel_size=31, power=2, margin=1, n_fft=2048, hop_length=None, win_length=None, window='hann', center=True, pad_mode='constant')

Extract harmonic components using HPSS (Harmonic-Percussive Source Separation).

This method separates the harmonic (tonal) components from the signal.

Parameters:

Name Type Description Default
kernel_size Union[_IntLike_co, tuple[_IntLike_co, _IntLike_co], list[_IntLike_co]]

Median filter size for HPSS.

31
power float

Exponent for the Weiner filter used in HPSS.

2
margin Union[_FloatLike_co, tuple[_FloatLike_co, _FloatLike_co], list[_FloatLike_co]]

Margin size for the separation.

1
n_fft int

Size of FFT window.

2048
hop_length int | None

Hop length for STFT.

None
win_length int | None

Window length for STFT.

None
window _WindowSpec

Window type for STFT.

'hann'
center bool

If True, center the frames.

True
pad_mode _PadModeSTFT

Padding mode for STFT.

'constant'

Returns:

Type Description
T_Processing

A new ChannelFrame containing the harmonic components.

Source code in wandas/frames/mixins/channel_processing_mixin.py
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def hpss_harmonic(
    self: T_Processing,
    kernel_size: Union[
        "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
    ] = 31,
    power: float = 2,
    margin: Union[
        "_FloatLike_co",
        tuple["_FloatLike_co", "_FloatLike_co"],
        list["_FloatLike_co"],
    ] = 1,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: "_WindowSpec" = "hann",
    center: bool = True,
    pad_mode: "_PadModeSTFT" = "constant",
) -> T_Processing:
    """
    Extract harmonic components using HPSS
     (Harmonic-Percussive Source Separation).

    This method separates the harmonic (tonal) components from the signal.

    Args:
        kernel_size: Median filter size for HPSS.
        power: Exponent for the Weiner filter used in HPSS.
        margin: Margin size for the separation.
        n_fft: Size of FFT window.
        hop_length: Hop length for STFT.
        win_length: Window length for STFT.
        window: Window type for STFT.
        center: If True, center the frames.
        pad_mode: Padding mode for STFT.

    Returns:
        A new ChannelFrame containing the harmonic components.
    """
    result = self.apply_operation(
        "hpss_harmonic",
        kernel_size=kernel_size,
        power=power,
        margin=margin,
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        center=center,
        pad_mode=pad_mode,
    )
    return cast(T_Processing, result)
hpss_percussive(kernel_size=31, power=2, margin=1, n_fft=2048, hop_length=None, win_length=None, window='hann', center=True, pad_mode='constant')

Extract percussive components using HPSS (Harmonic-Percussive Source Separation).

This method separates the percussive (tonal) components from the signal.

Parameters:

Name Type Description Default
kernel_size Union[_IntLike_co, tuple[_IntLike_co, _IntLike_co], list[_IntLike_co]]

Median filter size for HPSS.

31
power float

Exponent for the Weiner filter used in HPSS.

2
margin Union[_FloatLike_co, tuple[_FloatLike_co, _FloatLike_co], list[_FloatLike_co]]

Margin size for the separation.

1

Returns:

Type Description
T_Processing

A new ChannelFrame containing the harmonic components.

Source code in wandas/frames/mixins/channel_processing_mixin.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
def hpss_percussive(
    self: T_Processing,
    kernel_size: Union[
        "_IntLike_co", tuple["_IntLike_co", "_IntLike_co"], list["_IntLike_co"]
    ] = 31,
    power: float = 2,
    margin: Union[
        "_FloatLike_co",
        tuple["_FloatLike_co", "_FloatLike_co"],
        list["_FloatLike_co"],
    ] = 1,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: "_WindowSpec" = "hann",
    center: bool = True,
    pad_mode: "_PadModeSTFT" = "constant",
) -> T_Processing:
    """
    Extract percussive components using HPSS
    (Harmonic-Percussive Source Separation).

    This method separates the percussive (tonal) components from the signal.

    Args:
        kernel_size: Median filter size for HPSS.
        power: Exponent for the Weiner filter used in HPSS.
        margin: Margin size for the separation.

    Returns:
        A new ChannelFrame containing the harmonic components.
    """
    result = self.apply_operation(
        "hpss_percussive",
        kernel_size=kernel_size,
        power=power,
        margin=margin,
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        center=center,
        pad_mode=pad_mode,
    )
    return cast(T_Processing, result)
loudness_zwtv(field_type='free')

Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

This method computes the loudness of non-stationary signals according to the Zwicker method, as specified in ISO 532-1:2017. The loudness is calculated in sones, where a doubling of sones corresponds to a doubling of perceived loudness.

Parameters:

Name Type Description Default
field_type str

Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions) Default is 'free'.

'free'

Returns:

Type Description
T_Processing

New ChannelFrame containing time-varying loudness values in sones.

T_Processing

Each channel is processed independently.

T_Processing

The output sampling rate is adjusted based on the loudness

T_Processing

calculation time resolution (typically ~500 Hz for 2ms steps).

Raises:

Type Description
ValueError

If field_type is not 'free' or 'diffuse'

Examples:

Calculate loudness for a signal:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> loudness = signal.loudness_zwtv(field_type="free")
>>> loudness.plot(title="Time-varying Loudness")

Compare free field and diffuse field:

>>> loudness_free = signal.loudness_zwtv(field_type="free")
>>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")
Notes
  • The output contains time-varying loudness values in sones
  • Typical loudness: 1 sone ≈ 40 phon (loudness level)
  • The time resolution is approximately 2ms (determined by the algorithm)
  • For multi-channel signals, loudness is calculated per channel
  • The output sampling rate is updated to reflect the time resolution

Time axis convention: The time axis in the returned frame represents the start time of each 2ms analysis step. This differs slightly from the MoSQITo library, which uses the center time of each step. For example:

  • wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
  • MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

The difference is very small (~1ms) and does not affect the loudness values themselves. This design choice ensures consistency with wandas's time axis convention across all frame types.

References

ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method"

Source code in wandas/frames/mixins/channel_processing_mixin.py
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
def loudness_zwtv(self: T_Processing, field_type: str = "free") -> T_Processing:
    """
    Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

    This method computes the loudness of non-stationary signals according to
    the Zwicker method, as specified in ISO 532-1:2017. The loudness is
    calculated in sones, where a doubling of sones corresponds to a doubling
    of perceived loudness.

    Args:
        field_type: Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)
            Default is 'free'.

    Returns:
        New ChannelFrame containing time-varying loudness values in sones.
        Each channel is processed independently.
        The output sampling rate is adjusted based on the loudness
        calculation time resolution (typically ~500 Hz for 2ms steps).

    Raises:
        ValueError: If field_type is not 'free' or 'diffuse'

    Examples:
        Calculate loudness for a signal:
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> loudness = signal.loudness_zwtv(field_type="free")
        >>> loudness.plot(title="Time-varying Loudness")

        Compare free field and diffuse field:
        >>> loudness_free = signal.loudness_zwtv(field_type="free")
        >>> loudness_diffuse = signal.loudness_zwtv(field_type="diffuse")

    Notes:
        - The output contains time-varying loudness values in sones
        - Typical loudness: 1 sone ≈ 40 phon (loudness level)
        - The time resolution is approximately 2ms (determined by the algorithm)
        - For multi-channel signals, loudness is calculated per channel
        - The output sampling rate is updated to reflect the time resolution

        **Time axis convention:**
        The time axis in the returned frame represents the start time of
        each 2ms analysis step. This differs slightly from the MoSQITo
        library, which uses the center time of each step. For example:

        - wandas time: [0.000s, 0.002s, 0.004s, ...] (step start)
        - MoSQITo time: [0.001s, 0.003s, 0.005s, ...] (step center)

        The difference is very small (~1ms) and does not affect the loudness
        values themselves. This design choice ensures consistency with
        wandas's time axis convention across all frame types.

    References:
        ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
        Part 1: Zwicker method"
    """
    result = self.apply_operation("loudness_zwtv", field_type=field_type)

    # Sampling rate update is handled by the Operation class
    return cast(T_Processing, result)
loudness_zwst(field_type='free')

Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

This method computes the loudness of stationary (steady) signals according to the Zwicker method, as specified in ISO 532-1:2017. The loudness is calculated in sones, where a doubling of sones corresponds to a doubling of perceived loudness.

This method is suitable for analyzing steady sounds such as fan noise, constant machinery sounds, or other stationary signals.

Parameters:

Name Type Description Default
field_type str

Type of sound field. Options: - 'free': Free field (sound from a specific direction) - 'diffuse': Diffuse field (sound from all directions) Default is 'free'.

'free'

Returns:

Type Description
NDArrayReal

Loudness values in sones, one per channel. Shape: (n_channels,)

Raises:

Type Description
ValueError

If field_type is not 'free' or 'diffuse'

Examples:

Calculate steady-state loudness for a fan noise:

>>> import wandas as wd
>>> signal = wd.read_wav("fan_noise.wav")
>>> loudness = signal.loudness_zwst(field_type="free")
>>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
>>> print(f"Mean loudness: {loudness.mean():.2f} sones")

Compare free field and diffuse field:

>>> loudness_free = signal.loudness_zwst(field_type="free")
>>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
>>> print(f"Free field: {loudness_free[0]:.2f} sones")
>>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")
Notes
  • Returns a 1D array with one loudness value per channel
  • Typical loudness: 1 sone ≈ 40 phon (loudness level)
  • For multi-channel signals, loudness is calculated independently per channel
  • This method is designed for stationary signals (constant sounds)
  • For time-varying signals, use loudness_zwtv() instead
  • Similar to the rms property, returns NDArrayReal for consistency
References

ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method"

Source code in wandas/frames/mixins/channel_processing_mixin.py
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def loudness_zwst(self: T_Processing, field_type: str = "free") -> "NDArrayReal":
    """
    Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

    This method computes the loudness of stationary (steady) signals according to
    the Zwicker method, as specified in ISO 532-1:2017. The loudness is
    calculated in sones, where a doubling of sones corresponds to a doubling
    of perceived loudness.

    This method is suitable for analyzing steady sounds such as fan noise,
    constant machinery sounds, or other stationary signals.

    Args:
        field_type: Type of sound field. Options:
            - 'free': Free field (sound from a specific direction)
            - 'diffuse': Diffuse field (sound from all directions)
            Default is 'free'.

    Returns:
        Loudness values in sones, one per channel. Shape: (n_channels,)

    Raises:
        ValueError: If field_type is not 'free' or 'diffuse'

    Examples:
        Calculate steady-state loudness for a fan noise:
        >>> import wandas as wd
        >>> signal = wd.read_wav("fan_noise.wav")
        >>> loudness = signal.loudness_zwst(field_type="free")
        >>> print(f"Channel 0 loudness: {loudness[0]:.2f} sones")
        >>> print(f"Mean loudness: {loudness.mean():.2f} sones")

        Compare free field and diffuse field:
        >>> loudness_free = signal.loudness_zwst(field_type="free")
        >>> loudness_diffuse = signal.loudness_zwst(field_type="diffuse")
        >>> print(f"Free field: {loudness_free[0]:.2f} sones")
        >>> print(f"Diffuse field: {loudness_diffuse[0]:.2f} sones")

    Notes:
        - Returns a 1D array with one loudness value per channel
        - Typical loudness: 1 sone ≈ 40 phon (loudness level)
        - For multi-channel signals, loudness is calculated independently
          per channel
        - This method is designed for stationary signals (constant sounds)
        - For time-varying signals, use loudness_zwtv() instead
        - Similar to the rms property, returns NDArrayReal for consistency

    References:
        ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
        Part 1: Zwicker method"
    """
    # Treat self as a ProcessingFrameProtocol so mypy understands
    # where sampling_rate and data come from.
    from wandas.processing.psychoacoustic import LoudnessZwst
    from wandas.utils.types import NDArrayReal

    # Create operation instance
    operation = LoudnessZwst(self.sampling_rate, field_type=field_type)

    # Get data (triggers computation if lazy)
    data = self.data

    # Ensure data is 2D (n_channels, n_samples)
    if data.ndim == 1:
        data = data.reshape(1, -1)
    # Process the array using the public API and materialize to NumPy
    result = operation.process_array(data).compute()

    # Squeeze to get 1D array (n_channels,)
    loudness_values: NDArrayReal = result.squeeze()

    # Ensure it's 1D even for single channel
    if loudness_values.ndim == 0:
        loudness_values = loudness_values.reshape(1)

    return loudness_values
roughness_dw(overlap=0.5)

Calculate time-varying roughness using Daniel and Weber method.

Roughness is a psychoacoustic metric that quantifies the perceived harshness or roughness of a sound, measured in asper. This method implements the Daniel & Weber (1997) standard calculation.

The calculation follows the standard formula: R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

Parameters:

Name Type Description Default
overlap float

Overlapping coefficient for 200ms analysis windows (0.0 to 1.0). - overlap=0.5: 100ms hop → ~10 Hz output sampling rate - overlap=0.0: 200ms hop → ~5 Hz output sampling rate Default is 0.5.

0.5

Returns:

Type Description
T_Processing

New ChannelFrame containing time-varying roughness values in asper.

T_Processing

The output sampling rate depends on the overlap parameter.

Raises:

Type Description
ValueError

If overlap is not in the range [0.0, 1.0]

Examples:

Calculate roughness for a motor noise:

>>> import wandas as wd
>>> signal = wd.read_wav("motor_noise.wav")
>>> roughness = signal.roughness_dw(overlap=0.5)
>>> roughness.plot(ylabel="Roughness [asper]")

Analyze roughness statistics:

>>> mean_roughness = roughness.data.mean()
>>> max_roughness = roughness.data.max()
>>> print(f"Mean: {mean_roughness:.2f} asper")
>>> print(f"Max: {max_roughness:.2f} asper")

Compare before and after modification:

>>> before = wd.read_wav("motor_before.wav").roughness_dw()
>>> after = wd.read_wav("motor_after.wav").roughness_dw()
>>> improvement = before.data.mean() - after.data.mean()
>>> print(f"Roughness reduction: {improvement:.2f} asper")
Notes
  • Returns a ChannelFrame with time-varying roughness values
  • Typical roughness values: 0-2 asper for most sounds
  • Higher values indicate rougher, harsher sounds
  • For multi-channel signals, roughness is calculated independently per channel
  • This is the standard-compliant total roughness (R)
  • For detailed Bark-band analysis, use roughness_dw_spec() instead

Time axis convention: The time axis in the returned frame represents the start time of each 200ms analysis window. This differs from the MoSQITo library, which uses the center time of each window. For example:

  • wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
  • MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

The difference is constant (half the window duration = 100ms) and does not affect the roughness values themselves. This design choice ensures consistency with wandas's time axis convention across all frame types.

References

Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model." Acustica, 83, 113-123.

Source code in wandas/frames/mixins/channel_processing_mixin.py
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
def roughness_dw(self: T_Processing, overlap: float = 0.5) -> T_Processing:
    """Calculate time-varying roughness using Daniel and Weber method.

    Roughness is a psychoacoustic metric that quantifies the perceived
    harshness or roughness of a sound, measured in asper. This method
    implements the Daniel & Weber (1997) standard calculation.

    The calculation follows the standard formula:
    R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

    Args:
        overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
            - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
            - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
            Default is 0.5.

    Returns:
        New ChannelFrame containing time-varying roughness values in asper.
        The output sampling rate depends on the overlap parameter.

    Raises:
        ValueError: If overlap is not in the range [0.0, 1.0]

    Examples:
        Calculate roughness for a motor noise:
        >>> import wandas as wd
        >>> signal = wd.read_wav("motor_noise.wav")
        >>> roughness = signal.roughness_dw(overlap=0.5)
        >>> roughness.plot(ylabel="Roughness [asper]")

        Analyze roughness statistics:
        >>> mean_roughness = roughness.data.mean()
        >>> max_roughness = roughness.data.max()
        >>> print(f"Mean: {mean_roughness:.2f} asper")
        >>> print(f"Max: {max_roughness:.2f} asper")

        Compare before and after modification:
        >>> before = wd.read_wav("motor_before.wav").roughness_dw()
        >>> after = wd.read_wav("motor_after.wav").roughness_dw()
        >>> improvement = before.data.mean() - after.data.mean()
        >>> print(f"Roughness reduction: {improvement:.2f} asper")

    Notes:
        - Returns a ChannelFrame with time-varying roughness values
        - Typical roughness values: 0-2 asper for most sounds
        - Higher values indicate rougher, harsher sounds
        - For multi-channel signals, roughness is calculated independently
          per channel
        - This is the standard-compliant total roughness (R)
        - For detailed Bark-band analysis, use roughness_dw_spec() instead

        **Time axis convention:**
        The time axis in the returned frame represents the start time of
        each 200ms analysis window. This differs from the MoSQITo library,
        which uses the center time of each window. For example:

        - wandas time: [0.0s, 0.1s, 0.2s, ...] (window start)
        - MoSQITo time: [0.1s, 0.2s, 0.3s, ...] (window center)

        The difference is constant (half the window duration = 100ms) and
        does not affect the roughness values themselves. This design choice
        ensures consistency with wandas's time axis convention across all
        frame types.

    References:
        Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
        Implementation of an optimized model." Acustica, 83, 113-123.
    """
    logger.debug(f"Applying roughness_dw operation with overlap={overlap} (lazy)")
    result = self.apply_operation("roughness_dw", overlap=overlap)
    return cast(T_Processing, result)
roughness_dw_spec(overlap=0.5)

Calculate specific roughness with Bark-band frequency information.

This method returns detailed roughness analysis data organized by Bark frequency bands over time, allowing for frequency-specific roughness analysis. It uses the Daniel & Weber (1997) method.

The relationship between total roughness and specific roughness: R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

Parameters:

Name Type Description Default
overlap float

Overlapping coefficient for 200ms analysis windows (0.0 to 1.0). - overlap=0.5: 100ms hop → ~10 Hz output sampling rate - overlap=0.0: 200ms hop → ~5 Hz output sampling rate Default is 0.5.

0.5

Returns:

Type Description
RoughnessFrame

RoughnessFrame containing: - data: Specific roughness by Bark band, shape (47, n_time) for mono or (n_channels, 47, n_time) for multi-channel - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5) - time: Time axis for each analysis frame - overlap: Overlap coefficient used - plot(): Method for Bark-Time heatmap visualization

Raises:

Type Description
ValueError

If overlap is not in the range [0.0, 1.0]

Examples:

Analyze frequency-specific roughness:

>>> import wandas as wd
>>> import numpy as np
>>> signal = wd.read_wav("motor.wav")
>>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
>>>
>>> # Plot Bark-Time heatmap
>>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
>>>
>>> # Find dominant Bark band
>>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
>>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
>>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
>>>
>>> # Extract specific Bark band time series
>>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
>>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
>>>
>>> # Verify standard formula
>>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
>>> # This should match signal.roughness_dw(overlap=0.5).data
Notes
  • Returns a RoughnessFrame (not ChannelFrame)
  • Contains 47 Bark bands from 0.5 to 23.5 Bark
  • Each Bark band corresponds to a critical band of hearing
  • Useful for identifying which frequencies contribute most to roughness
  • The specific roughness can be integrated to obtain total roughness
  • For simple time-series analysis, use roughness_dw() instead

Time axis convention: The time axis represents the start time of each 200ms analysis window, consistent with roughness_dw() and other wandas methods.

References

Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model." Acustica, 83, 113-123.

Source code in wandas/frames/mixins/channel_processing_mixin.py
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
def roughness_dw_spec(self: T_Processing, overlap: float = 0.5) -> "RoughnessFrame":
    """Calculate specific roughness with Bark-band frequency information.

    This method returns detailed roughness analysis data organized by
    Bark frequency bands over time, allowing for frequency-specific
    roughness analysis. It uses the Daniel & Weber (1997) method.

    The relationship between total roughness and specific roughness:
    R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

    Args:
        overlap: Overlapping coefficient for 200ms analysis windows (0.0 to 1.0).
            - overlap=0.5: 100ms hop → ~10 Hz output sampling rate
            - overlap=0.0: 200ms hop → ~5 Hz output sampling rate
            Default is 0.5.

    Returns:
        RoughnessFrame containing:
            - data: Specific roughness by Bark band, shape (47, n_time)
                    for mono or (n_channels, 47, n_time) for multi-channel
            - bark_axis: Frequency axis in Bark scale (47 values, 0.5-23.5)
            - time: Time axis for each analysis frame
            - overlap: Overlap coefficient used
            - plot(): Method for Bark-Time heatmap visualization

    Raises:
        ValueError: If overlap is not in the range [0.0, 1.0]

    Examples:
        Analyze frequency-specific roughness:
        >>> import wandas as wd
        >>> import numpy as np
        >>> signal = wd.read_wav("motor.wav")
        >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
        >>>
        >>> # Plot Bark-Time heatmap
        >>> roughness_spec.plot(cmap="viridis", title="Roughness Analysis")
        >>>
        >>> # Find dominant Bark band
        >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
        >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
        >>> print(f"Most contributing band: {dominant_bark:.1f} Bark")
        >>>
        >>> # Extract specific Bark band time series
        >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
        >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]
        >>>
        >>> # Verify standard formula
        >>> total_roughness = 0.25 * roughness_spec.data.sum(axis=-2)
        >>> # This should match signal.roughness_dw(overlap=0.5).data

    Notes:
        - Returns a RoughnessFrame (not ChannelFrame)
        - Contains 47 Bark bands from 0.5 to 23.5 Bark
        - Each Bark band corresponds to a critical band of hearing
        - Useful for identifying which frequencies contribute most to roughness
        - The specific roughness can be integrated to obtain total roughness
        - For simple time-series analysis, use roughness_dw() instead

        **Time axis convention:**
        The time axis represents the start time of each 200ms analysis
        window, consistent with roughness_dw() and other wandas methods.

    References:
        Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
        Implementation of an optimized model." Acustica, 83, 113-123.
    """

    params = {"overlap": overlap}
    operation_name = "roughness_dw_spec"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance via factory
    operation = create_operation(operation_name, self.sampling_rate, **params)

    # Apply processing lazily to self._data (Dask)
    r_spec_dask = operation.process(self._data)

    # Get metadata updates (sampling rate, bark_axis)
    metadata_updates = operation.get_metadata_updates()

    # Build metadata and history
    new_metadata = {**self.metadata, **params}
    new_history = [
        *self.operation_history,
        {"operation": operation_name, "params": params},
    ]

    # Extract bark_axis with proper type handling
    bark_axis_value = metadata_updates.get("bark_axis")
    if bark_axis_value is None:
        raise ValueError("Operation did not provide bark_axis in metadata")

    # Create RoughnessFrame. operation.get_metadata_updates() should provide
    # sampling_rate and bark_axis
    roughness_frame = RoughnessFrame(
        data=r_spec_dask,
        sampling_rate=metadata_updates.get("sampling_rate", self.sampling_rate),
        bark_axis=bark_axis_value,
        overlap=overlap,
        label=f"{self.label}_roughness_spec" if self.label else "roughness_spec",
        metadata=new_metadata,
        operation_history=new_history,
        channel_metadata=self._channel_metadata,
        previous=cast("BaseFrame[NDArrayReal]", self),
    )

    logger.debug(
        "Created RoughnessFrame via operation %s, shape=%s, sampling_rate=%.2f Hz",
        operation_name,
        r_spec_dask.shape,
        roughness_frame.sampling_rate,
    )

    return roughness_frame
fade(fade_ms=50)

Apply symmetric fade-in and fade-out to the signal using Tukey window.

This method applies a symmetric fade-in and fade-out envelope to the signal using a Tukey (tapered cosine) window. The fade duration is the same for both the beginning and end of the signal.

Parameters:

Name Type Description Default
fade_ms float

Fade duration in milliseconds for each end of the signal. The total fade duration is 2 * fade_ms. Default is 50 ms. Must be positive and less than half the signal duration.

50

Returns:

Type Description
T_Processing

New ChannelFrame containing the faded signal

Raises:

Type Description
ValueError

If fade_ms is negative or too long for the signal

Examples:

>>> import wandas as wd
>>> signal = wd.read_wav("audio.wav")
>>> # Apply 10ms fade-in and fade-out
>>> faded = signal.fade(fade_ms=10.0)
>>> # Apply very short fade (almost no effect)
>>> faded_short = signal.fade(fade_ms=0.1)
Notes
  • Uses SciPy's Tukey window for smooth fade transitions
  • Fade is applied symmetrically to both ends of the signal
  • The Tukey window alpha parameter is computed automatically based on the fade duration and signal length
  • For multi-channel signals, the same fade envelope is applied to all channels
  • Lazy evaluation is preserved - computation occurs only when needed
Source code in wandas/frames/mixins/channel_processing_mixin.py
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
def fade(self: T_Processing, fade_ms: float = 50) -> T_Processing:
    """Apply symmetric fade-in and fade-out to the signal using Tukey window.

    This method applies a symmetric fade-in and fade-out envelope to the signal
    using a Tukey (tapered cosine) window. The fade duration is the same for
    both the beginning and end of the signal.

    Args:
        fade_ms: Fade duration in milliseconds for each end of the signal.
            The total fade duration is 2 * fade_ms. Default is 50 ms.
            Must be positive and less than half the signal duration.

    Returns:
        New ChannelFrame containing the faded signal

    Raises:
        ValueError: If fade_ms is negative or too long for the signal

    Examples:
        >>> import wandas as wd
        >>> signal = wd.read_wav("audio.wav")
        >>> # Apply 10ms fade-in and fade-out
        >>> faded = signal.fade(fade_ms=10.0)
        >>> # Apply very short fade (almost no effect)
        >>> faded_short = signal.fade(fade_ms=0.1)

    Notes:
        - Uses SciPy's Tukey window for smooth fade transitions
        - Fade is applied symmetrically to both ends of the signal
        - The Tukey window alpha parameter is computed automatically
          based on the fade duration and signal length
        - For multi-channel signals, the same fade envelope is applied
          to all channels
        - Lazy evaluation is preserved - computation occurs only when needed
    """
    logger.debug(f"Setting up fade: fade_ms={fade_ms} (lazy)")
    result = self.apply_operation("fade", fade_ms=fade_ms)
    return cast(T_Processing, result)
Functions
channel_transform_mixin

Module providing mixins related to frequency transformations and transform operations.

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
ChannelTransformMixin

Mixin providing methods related to frequency transformations.

This mixin provides operations related to frequency analysis and transformations such as FFT, STFT, and Welch method.

Source code in wandas/frames/mixins/channel_transform_mixin.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
class ChannelTransformMixin:
    """Mixin providing methods related to frequency transformations.

    This mixin provides operations related to frequency analysis and
    transformations such as FFT, STFT, and Welch method.
    """

    def fft(
        self: T_Transform, n_fft: int | None = None, window: str = "hann"
    ) -> "SpectralFrame":
        """Calculate Fast Fourier Transform (FFT).

        Args:
            n_fft: Number of FFT points. Default is the next power of 2 of the data
                length.
            window: Window type. Default is "hann".

        Returns:
            SpectralFrame containing FFT results
        """
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import FFT, create_operation

        params = {"n_fft": n_fft, "window": window}
        operation_name = "fft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("FFT", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        if n_fft is None:
            is_even = spectrum_data.shape[-1] % 2 == 0
            _n_fft = (
                spectrum_data.shape[-1] * 2 - 2
                if is_even
                else spectrum_data.shape[-1] * 2 - 1
            )
        else:
            _n_fft = n_fft

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        return SpectralFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            n_fft=_n_fft,
            window=operation.window,
            label=f"Spectrum of {self.label}",
            metadata={**self.metadata, "window": window, "n_fft": _n_fft},
            operation_history=[
                *self.operation_history,
                {"operation": "fft", "params": {"n_fft": _n_fft, "window": window}},
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def welch(
        self: T_Transform,
        n_fft: int | None = None,
        hop_length: int | None = None,
        win_length: int = 2048,
        window: str = "hann",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Calculate power spectral density using Welch's method.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            average: Method for averaging segments. Default is "mean".

        Returns:
            SpectralFrame containing power spectral density
        """
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import Welch, create_operation

        params = dict(
            n_fft=n_fft or win_length,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            average=average,
        )
        operation_name = "welch"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("Welch", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        return SpectralFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Spectrum of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": "welch", "params": params},
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def noct_spectrum(
        self: T_Transform,
        fmin: float = 25,
        fmax: float = 20000,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ) -> "NOctFrame":
        """Calculate N-octave band spectrum.

        Args:
            fmin: Minimum center frequency (Hz). Default is 25 Hz.
            fmax: Maximum center frequency (Hz). Default is 20000 Hz.
            n: Band division (1: octave, 3: 1/3 octave). Default is 3.
            G: Reference gain (dB). Default is 10 dB.
            fr: Reference frequency (Hz). Default is 1000 Hz.

        Returns:
            NOctFrame containing N-octave band spectrum
        """
        from wandas.processing import NOctSpectrum, create_operation

        from ..noct import NOctFrame

        params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
        operation_name = "noct_spectrum"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("NOctSpectrum", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        return NOctFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            fmin=fmin,
            fmax=fmax,
            n=n,
            G=G,
            fr=fr,
            label=f"1/{n}Oct of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {
                    "operation": "noct_spectrum",
                    "params": params,
                },
            ],
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def stft(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
    ) -> "SpectrogramFrame":
        """Calculate Short-Time Fourier Transform.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".

        Returns:
            SpectrogramFrame containing STFT results
        """
        from wandas.processing import STFT, create_operation

        from ..spectrogram import SpectrogramFrame

        # Set hop length and window length
        _hop_length = hop_length if hop_length is not None else n_fft // 4
        _win_length = win_length if win_length is not None else n_fft

        params = {
            "n_fft": n_fft,
            "hop_length": _hop_length,
            "win_length": _win_length,
            "window": window,
        }
        operation_name = "stft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("STFT", operation)

        # Apply processing to data
        spectrogram_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectrogramFrame with operation {operation_name} added to graph"  # noqa: E501
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new instance
        return SpectrogramFrame(
            data=spectrogram_data,
            sampling_rate=self.sampling_rate,
            n_fft=n_fft,
            hop_length=_hop_length,
            win_length=_win_length,
            window=window,
            label=f"stft({self.label})",
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
            previous=base_self,
        )

    def coherence(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
    ) -> "SpectralFrame":
        """Calculate magnitude squared coherence.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.

        Returns:
            SpectralFrame containing magnitude squared coherence
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.processing import Coherence, create_operation

        from ..spectral import SpectralFrame

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
        }
        operation_name = "coherence"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("Coherence", operation)

        # Apply processing to data
        coherence_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"$\\gamma_{{{in_ch.label}, {out_ch.label}}}$"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(
                    in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
                )
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=coherence_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Coherence of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )

    def csd(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Calculate cross-spectral density matrix.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.
            scaling: Scaling method. Options: "spectrum", "density".
            average: Method for averaging segments. Default is "mean".

        Returns:
            SpectralFrame containing cross-spectral density matrix
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import CSD, create_operation

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
            "scaling": scaling,
            "average": average,
        }
        operation_name = "csd"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("CSD", operation)

        # Apply processing to data
        csd_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"{operation_name}({in_ch.label}, {out_ch.label})"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(
                    in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
                )
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=csd_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"$C_{{{in_ch.label}, {out_ch.label}}}$",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )

    def transfer_function(
        self: T_Transform,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ) -> "SpectralFrame":
        """Calculate transfer function matrix.

        The transfer function represents the signal transfer characteristics between
        channels in the frequency domain and represents the input-output relationship
        of the system.

        Args:
            n_fft: Number of FFT points. Default is 2048.
            hop_length: Number of samples between frames.
                Default is n_fft//4.
            win_length: Window length. Default is n_fft.
            window: Window type. Default is "hann".
            detrend: Detrend method. Options: "constant", "linear", None.
            scaling: Scaling method. Options: "spectrum", "density".
            average: Method for averaging segments. Default is "mean".

        Returns:
            SpectralFrame containing transfer function matrix
        """
        from wandas.core.metadata import ChannelMetadata
        from wandas.frames.spectral import SpectralFrame
        from wandas.processing import TransferFunction, create_operation

        params = {
            "n_fft": n_fft,
            "hop_length": hop_length,
            "win_length": win_length,
            "window": window,
            "detrend": detrend,
            "scaling": scaling,
            "average": average,
        }
        operation_name = "transfer_function"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("TransferFunction", operation)

        # Apply processing to data
        tf_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Cast self as BaseFrame type
        base_self = cast(BaseFrame[Any], self)

        # Create new channel metadata
        channel_metadata = []
        for in_ch in self._channel_metadata:
            for out_ch in self._channel_metadata:
                meta = ChannelMetadata()
                meta.label = f"$H_{{{in_ch.label}, {out_ch.label}}}$"
                meta.unit = ""
                meta.ref = 1
                meta["metadata"] = dict(
                    in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
                )
                channel_metadata.append(meta)

        # Create new instance
        return SpectralFrame(
            data=tf_data,
            sampling_rate=self.sampling_rate,
            n_fft=operation.n_fft,
            window=operation.window,
            label=f"Transfer function of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {"operation": operation_name, "params": params},
            ],
            channel_metadata=channel_metadata,
            previous=base_self,
        )
Functions
fft(n_fft=None, window='hann')

Calculate Fast Fourier Transform (FFT).

Parameters:

Name Type Description Default
n_fft int | None

Number of FFT points. Default is the next power of 2 of the data length.

None
window str

Window type. Default is "hann".

'hann'

Returns:

Type Description
SpectralFrame

SpectralFrame containing FFT results

Source code in wandas/frames/mixins/channel_transform_mixin.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def fft(
    self: T_Transform, n_fft: int | None = None, window: str = "hann"
) -> "SpectralFrame":
    """Calculate Fast Fourier Transform (FFT).

    Args:
        n_fft: Number of FFT points. Default is the next power of 2 of the data
            length.
        window: Window type. Default is "hann".

    Returns:
        SpectralFrame containing FFT results
    """
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import FFT, create_operation

    params = {"n_fft": n_fft, "window": window}
    operation_name = "fft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("FFT", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    if n_fft is None:
        is_even = spectrum_data.shape[-1] % 2 == 0
        _n_fft = (
            spectrum_data.shape[-1] * 2 - 2
            if is_even
            else spectrum_data.shape[-1] * 2 - 1
        )
    else:
        _n_fft = n_fft

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    return SpectralFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        n_fft=_n_fft,
        window=operation.window,
        label=f"Spectrum of {self.label}",
        metadata={**self.metadata, "window": window, "n_fft": _n_fft},
        operation_history=[
            *self.operation_history,
            {"operation": "fft", "params": {"n_fft": _n_fft, "window": window}},
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
welch(n_fft=None, hop_length=None, win_length=2048, window='hann', average='mean')

Calculate power spectral density using Welch's method.

Parameters:

Name Type Description Default
n_fft int | None

Number of FFT points. Default is 2048.

None
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int

Window length. Default is n_fft.

2048
window str

Window type. Default is "hann".

'hann'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

SpectralFrame containing power spectral density

Source code in wandas/frames/mixins/channel_transform_mixin.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def welch(
    self: T_Transform,
    n_fft: int | None = None,
    hop_length: int | None = None,
    win_length: int = 2048,
    window: str = "hann",
    average: str = "mean",
) -> "SpectralFrame":
    """Calculate power spectral density using Welch's method.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        average: Method for averaging segments. Default is "mean".

    Returns:
        SpectralFrame containing power spectral density
    """
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import Welch, create_operation

    params = dict(
        n_fft=n_fft or win_length,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        average=average,
    )
    operation_name = "welch"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("Welch", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    return SpectralFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Spectrum of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": "welch", "params": params},
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
noct_spectrum(fmin=25, fmax=20000, n=3, G=10, fr=1000)

Calculate N-octave band spectrum.

Parameters:

Name Type Description Default
fmin float

Minimum center frequency (Hz). Default is 25 Hz.

25
fmax float

Maximum center frequency (Hz). Default is 20000 Hz.

20000
n int

Band division (1: octave, 3: 1/3 octave). Default is 3.

3
G int

Reference gain (dB). Default is 10 dB.

10
fr int

Reference frequency (Hz). Default is 1000 Hz.

1000

Returns:

Type Description
NOctFrame

NOctFrame containing N-octave band spectrum

Source code in wandas/frames/mixins/channel_transform_mixin.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def noct_spectrum(
    self: T_Transform,
    fmin: float = 25,
    fmax: float = 20000,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
) -> "NOctFrame":
    """Calculate N-octave band spectrum.

    Args:
        fmin: Minimum center frequency (Hz). Default is 25 Hz.
        fmax: Maximum center frequency (Hz). Default is 20000 Hz.
        n: Band division (1: octave, 3: 1/3 octave). Default is 3.
        G: Reference gain (dB). Default is 10 dB.
        fr: Reference frequency (Hz). Default is 1000 Hz.

    Returns:
        NOctFrame containing N-octave band spectrum
    """
    from wandas.processing import NOctSpectrum, create_operation

    from ..noct import NOctFrame

    params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
    operation_name = "noct_spectrum"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("NOctSpectrum", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    return NOctFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        fmin=fmin,
        fmax=fmax,
        n=n,
        G=G,
        fr=fr,
        label=f"1/{n}Oct of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {
                "operation": "noct_spectrum",
                "params": params,
            },
        ],
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
stft(n_fft=2048, hop_length=None, win_length=None, window='hann')

Calculate Short-Time Fourier Transform.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'

Returns:

Type Description
SpectrogramFrame

SpectrogramFrame containing STFT results

Source code in wandas/frames/mixins/channel_transform_mixin.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def stft(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
) -> "SpectrogramFrame":
    """Calculate Short-Time Fourier Transform.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".

    Returns:
        SpectrogramFrame containing STFT results
    """
    from wandas.processing import STFT, create_operation

    from ..spectrogram import SpectrogramFrame

    # Set hop length and window length
    _hop_length = hop_length if hop_length is not None else n_fft // 4
    _win_length = win_length if win_length is not None else n_fft

    params = {
        "n_fft": n_fft,
        "hop_length": _hop_length,
        "win_length": _win_length,
        "window": window,
    }
    operation_name = "stft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("STFT", operation)

    # Apply processing to data
    spectrogram_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectrogramFrame with operation {operation_name} added to graph"  # noqa: E501
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new instance
    return SpectrogramFrame(
        data=spectrogram_data,
        sampling_rate=self.sampling_rate,
        n_fft=n_fft,
        hop_length=_hop_length,
        win_length=_win_length,
        window=window,
        label=f"stft({self.label})",
        metadata=self.metadata,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
        previous=base_self,
    )
coherence(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant')

Calculate magnitude squared coherence.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'

Returns:

Type Description
SpectralFrame

SpectralFrame containing magnitude squared coherence

Source code in wandas/frames/mixins/channel_transform_mixin.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def coherence(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
) -> "SpectralFrame":
    """Calculate magnitude squared coherence.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.

    Returns:
        SpectralFrame containing magnitude squared coherence
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.processing import Coherence, create_operation

    from ..spectral import SpectralFrame

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
    }
    operation_name = "coherence"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("Coherence", operation)

    # Apply processing to data
    coherence_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"$\\gamma_{{{in_ch.label}, {out_ch.label}}}$"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(
                in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
            )
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=coherence_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Coherence of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )
csd(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Calculate cross-spectral density matrix.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'
scaling str

Scaling method. Options: "spectrum", "density".

'spectrum'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

SpectralFrame containing cross-spectral density matrix

Source code in wandas/frames/mixins/channel_transform_mixin.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def csd(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
) -> "SpectralFrame":
    """Calculate cross-spectral density matrix.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.
        scaling: Scaling method. Options: "spectrum", "density".
        average: Method for averaging segments. Default is "mean".

    Returns:
        SpectralFrame containing cross-spectral density matrix
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import CSD, create_operation

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
        "scaling": scaling,
        "average": average,
    }
    operation_name = "csd"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("CSD", operation)

    # Apply processing to data
    csd_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"{operation_name}({in_ch.label}, {out_ch.label})"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(
                in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
            )
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=csd_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"$C_{{{in_ch.label}, {out_ch.label}}}$",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )
transfer_function(n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Calculate transfer function matrix.

The transfer function represents the signal transfer characteristics between channels in the frequency domain and represents the input-output relationship of the system.

Parameters:

Name Type Description Default
n_fft int

Number of FFT points. Default is 2048.

2048
hop_length int | None

Number of samples between frames. Default is n_fft//4.

None
win_length int | None

Window length. Default is n_fft.

None
window str

Window type. Default is "hann".

'hann'
detrend str

Detrend method. Options: "constant", "linear", None.

'constant'
scaling str

Scaling method. Options: "spectrum", "density".

'spectrum'
average str

Method for averaging segments. Default is "mean".

'mean'

Returns:

Type Description
SpectralFrame

SpectralFrame containing transfer function matrix

Source code in wandas/frames/mixins/channel_transform_mixin.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
def transfer_function(
    self: T_Transform,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
) -> "SpectralFrame":
    """Calculate transfer function matrix.

    The transfer function represents the signal transfer characteristics between
    channels in the frequency domain and represents the input-output relationship
    of the system.

    Args:
        n_fft: Number of FFT points. Default is 2048.
        hop_length: Number of samples between frames.
            Default is n_fft//4.
        win_length: Window length. Default is n_fft.
        window: Window type. Default is "hann".
        detrend: Detrend method. Options: "constant", "linear", None.
        scaling: Scaling method. Options: "spectrum", "density".
        average: Method for averaging segments. Default is "mean".

    Returns:
        SpectralFrame containing transfer function matrix
    """
    from wandas.core.metadata import ChannelMetadata
    from wandas.frames.spectral import SpectralFrame
    from wandas.processing import TransferFunction, create_operation

    params = {
        "n_fft": n_fft,
        "hop_length": hop_length,
        "win_length": win_length,
        "window": window,
        "detrend": detrend,
        "scaling": scaling,
        "average": average,
    }
    operation_name = "transfer_function"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("TransferFunction", operation)

    # Apply processing to data
    tf_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Cast self as BaseFrame type
    base_self = cast(BaseFrame[Any], self)

    # Create new channel metadata
    channel_metadata = []
    for in_ch in self._channel_metadata:
        for out_ch in self._channel_metadata:
            meta = ChannelMetadata()
            meta.label = f"$H_{{{in_ch.label}, {out_ch.label}}}$"
            meta.unit = ""
            meta.ref = 1
            meta["metadata"] = dict(
                in_ch=in_ch["metadata"], out_ch=out_ch["metadata"]
            )
            channel_metadata.append(meta)

    # Create new instance
    return SpectralFrame(
        data=tf_data,
        sampling_rate=self.sampling_rate,
        n_fft=operation.n_fft,
        window=operation.window,
        label=f"Transfer function of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {"operation": operation_name, "params": params},
        ],
        channel_metadata=channel_metadata,
        previous=base_self,
    )
protocols

Common protocol definition module.

This module contains common protocols used by mixin classes.

Attributes
logger = logging.getLogger(__name__) module-attribute
T_Base = TypeVar('T_Base', bound='BaseFrameProtocol') module-attribute
T_Processing = TypeVar('T_Processing', bound=ProcessingFrameProtocol) module-attribute
T_Transform = TypeVar('T_Transform', bound=TransformFrameProtocol) module-attribute
__all__ = ['BaseFrameProtocol', 'ProcessingFrameProtocol', 'TransformFrameProtocol', 'T_Processing'] module-attribute
Classes
BaseFrameProtocol

Bases: Protocol

Protocol that defines basic frame operations.

Defines the basic methods and properties provided by all frame classes.

Source code in wandas/frames/mixins/protocols.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@runtime_checkable
class BaseFrameProtocol(Protocol):
    """Protocol that defines basic frame operations.

    Defines the basic methods and properties provided by all frame classes.
    """

    _data: DaArray
    sampling_rate: float
    _channel_metadata: list[ChannelMetadata]
    metadata: dict[str, Any]
    operation_history: list[dict[str, Any]]
    label: str

    @property
    def duration(self) -> float:
        """Returns the duration in seconds."""
        ...

    @property
    def data(self) -> NDArrayReal:
        """Returns the computed data as a NumPy array.

        Implementations should materialize any lazy computation (e.g. Dask)
        and return a concrete NumPy array.
        """
        ...

    def label2index(self, label: str) -> int:
        """
        Get the index from a channel label.
        """
        ...

    def apply_operation(
        self, operation_name: str, **params: Any
    ) -> "BaseFrameProtocol":
        """Apply a named operation.

        Args:
            operation_name: Name of the operation to apply
            **params: Parameters to pass to the operation

        Returns:
            A new frame instance with the operation applied
        """
        ...

    def _create_new_instance(self: T_Base, data: DaArray, **kwargs: Any) -> T_Base:
        """Create a new instance of the frame with updated data and metadata.
        Args:
            data: The new data for the frame
            metadata: The new metadata for the frame
            operation_history: The new operation history for the frame
            channel_metadata: The new channel metadata for the frame
        Returns:
            A new instance of the frame with the updated data and metadata
        """
        ...
Attributes
sampling_rate instance-attribute
metadata instance-attribute
operation_history instance-attribute
label instance-attribute
duration property

Returns the duration in seconds.

data property

Returns the computed data as a NumPy array.

Implementations should materialize any lazy computation (e.g. Dask) and return a concrete NumPy array.

Functions
label2index(label)

Get the index from a channel label.

Source code in wandas/frames/mixins/protocols.py
48
49
50
51
52
def label2index(self, label: str) -> int:
    """
    Get the index from a channel label.
    """
    ...
apply_operation(operation_name, **params)

Apply a named operation.

Parameters:

Name Type Description Default
operation_name str

Name of the operation to apply

required
**params Any

Parameters to pass to the operation

{}

Returns:

Type Description
BaseFrameProtocol

A new frame instance with the operation applied

Source code in wandas/frames/mixins/protocols.py
54
55
56
57
58
59
60
61
62
63
64
65
66
def apply_operation(
    self, operation_name: str, **params: Any
) -> "BaseFrameProtocol":
    """Apply a named operation.

    Args:
        operation_name: Name of the operation to apply
        **params: Parameters to pass to the operation

    Returns:
        A new frame instance with the operation applied
    """
    ...
ProcessingFrameProtocol

Bases: BaseFrameProtocol, Protocol

Protocol that defines operations related to signal processing.

Defines methods that provide frame operations related to signal processing.

Source code in wandas/frames/mixins/protocols.py
81
82
83
84
85
86
87
88
@runtime_checkable
class ProcessingFrameProtocol(BaseFrameProtocol, Protocol):
    """Protocol that defines operations related to signal processing.

    Defines methods that provide frame operations related to signal processing.
    """

    pass
TransformFrameProtocol

Bases: BaseFrameProtocol, Protocol

Protocol related to transform operations.

Defines methods that provide operations such as frequency analysis and spectral transformation.

Source code in wandas/frames/mixins/protocols.py
91
92
93
94
95
96
97
98
99
@runtime_checkable
class TransformFrameProtocol(BaseFrameProtocol, Protocol):
    """Protocol related to transform operations.

    Defines methods that provide operations such as frequency analysis and
    spectral transformation.
    """

    pass

noct

Attributes
dask_delayed = dask.delayed module-attribute
da_from_delayed = da.from_delayed module-attribute
da_from_array = da.from_array module-attribute
logger = logging.getLogger(__name__) module-attribute
S = TypeVar('S', bound='BaseFrame[Any]') module-attribute
Classes
NOctFrame

Bases: BaseFrame[NDArrayReal]

Class for handling N-octave band analysis data.

This class represents frequency data analyzed in fractional octave bands, typically used in acoustic and vibration analysis. It handles real-valued data representing energy or power in each frequency band, following standard acoustical band definitions.

Parameters

data : DaArray The N-octave band data. Must be a dask array with shape: - (channels, frequency_bins) for multi-channel data - (frequency_bins,) for single-channel data, which will be reshaped to (1, frequency_bins) sampling_rate : float The sampling rate of the original time-domain signal in Hz. fmin : float, default=0 Lower frequency bound in Hz. fmax : float, default=0 Upper frequency bound in Hz. n : int, default=3 Number of bands per octave (e.g., 3 for third-octave bands). G : int, default=10 Reference band number according to IEC 61260-1:2014. fr : int, default=1000 Reference frequency in Hz, typically 1000 Hz for acoustic analysis. label : str, optional A label for the frame. metadata : dict, optional Additional metadata for the frame. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel in the frame. previous : BaseFrame, optional The frame that this frame was derived from.

Attributes

freqs : NDArrayReal The center frequencies of each band in Hz, calculated according to the standard fractional octave band definitions. dB : NDArrayReal The spectrum in decibels relative to channel reference values. dBA : NDArrayReal The A-weighted spectrum in decibels, applying frequency weighting for better correlation with perceived loudness. fmin : float Lower frequency bound in Hz. fmax : float Upper frequency bound in Hz. n : int Number of bands per octave. G : int Reference band number. fr : int Reference frequency in Hz.

Examples

Create an N-octave band spectrum from a time-domain signal:

signal = ChannelFrame.from_wav("audio.wav") spectrum = signal.noct_spectrum(fmin=20, fmax=20000, n=3)

Plot the N-octave band spectrum:

spectrum.plot()

Plot with A-weighting applied:

spectrum.plot(Aw=True)

Notes
  • Binary operations (addition, multiplication, etc.) are not currently supported for N-octave band data.
  • The actual frequency bands are determined by the parameters n, G, and fr according to IEC 61260-1:2014 standard for fractional octave band filters.
  • The class follows acoustic standards for band definitions and analysis, making it suitable for noise measurements and sound level analysis.
  • A-weighting is available for better correlation with human hearing perception, following IEC 61672-1:2013.
Source code in wandas/frames/noct.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
class NOctFrame(BaseFrame[NDArrayReal]):
    """
    Class for handling N-octave band analysis data.

    This class represents frequency data analyzed in fractional octave bands,
    typically used in acoustic and vibration analysis. It handles real-valued
    data representing energy or power in each frequency band, following standard
    acoustical band definitions.

    Parameters
    ----------
    data : DaArray
        The N-octave band data. Must be a dask array with shape:
        - (channels, frequency_bins) for multi-channel data
        - (frequency_bins,) for single-channel data, which will be
          reshaped to (1, frequency_bins)
    sampling_rate : float
        The sampling rate of the original time-domain signal in Hz.
    fmin : float, default=0
        Lower frequency bound in Hz.
    fmax : float, default=0
        Upper frequency bound in Hz.
    n : int, default=3
        Number of bands per octave (e.g., 3 for third-octave bands).
    G : int, default=10
        Reference band number according to IEC 61260-1:2014.
    fr : int, default=1000
        Reference frequency in Hz, typically 1000 Hz for acoustic analysis.
    label : str, optional
        A label for the frame.
    metadata : dict, optional
        Additional metadata for the frame.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel in the frame.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

    Attributes
    ----------
    freqs : NDArrayReal
        The center frequencies of each band in Hz, calculated according to
        the standard fractional octave band definitions.
    dB : NDArrayReal
        The spectrum in decibels relative to channel reference values.
    dBA : NDArrayReal
        The A-weighted spectrum in decibels, applying frequency weighting
        for better correlation with perceived loudness.
    fmin : float
        Lower frequency bound in Hz.
    fmax : float
        Upper frequency bound in Hz.
    n : int
        Number of bands per octave.
    G : int
        Reference band number.
    fr : int
        Reference frequency in Hz.

    Examples
    --------
    Create an N-octave band spectrum from a time-domain signal:
    >>> signal = ChannelFrame.from_wav("audio.wav")
    >>> spectrum = signal.noct_spectrum(fmin=20, fmax=20000, n=3)

    Plot the N-octave band spectrum:
    >>> spectrum.plot()

    Plot with A-weighting applied:
    >>> spectrum.plot(Aw=True)

    Notes
    -----
    - Binary operations (addition, multiplication, etc.) are not currently
      supported for N-octave band data.
    - The actual frequency bands are determined by the parameters n, G, and fr
      according to IEC 61260-1:2014 standard for fractional octave band filters.
    - The class follows acoustic standards for band definitions and analysis,
      making it suitable for noise measurements and sound level analysis.
    - A-weighting is available for better correlation with human hearing
      perception, following IEC 61672-1:2013.
    """

    fmin: float
    fmax: float
    n: int
    G: int
    fr: int

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        fmin: float = 0,
        fmax: float = 0,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> None:
        """
        Initialize a NOctFrame instance.

        Sets up N-octave band analysis parameters and prepares the frame for
        storing band-filtered data. Data shape is validated to ensure compatibility
        with N-octave band analysis.

        See class docstring for parameter descriptions.
        """
        self.n = n
        self.G = G
        self.fr = fr
        self.fmin = fmin
        self.fmax = fmax
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def dB(self) -> NDArrayReal:  # noqa: N802
        """
        Get the spectrum in decibels relative to each channel's reference value.

        The reference value for each channel is specified in its metadata.
        A minimum value of -120 dB is enforced to avoid numerical issues.

        Returns
        -------
        NDArrayReal
            The spectrum in decibels. Shape matches the input data shape:
            (channels, frequency_bins).
        """
        # Collect dB reference values from _channel_metadata
        ref = np.array([ch.ref for ch in self._channel_metadata])
        # Convert to dB
        # Use either the maximum value or 1e-12 to avoid division by zero
        level: NDArrayReal = 20 * np.log10(
            np.maximum(self.data / ref[..., np.newaxis], 1e-12)
        )
        return level

    @property
    def dBA(self) -> NDArrayReal:  # noqa: N802
        """
        Get the A-weighted spectrum in decibels.

        A-weighting applies a frequency-dependent weighting filter that approximates
        the human ear's response to different frequencies. This is particularly useful
        for analyzing noise and acoustic measurements as it provides a better
        correlation with perceived loudness.

        The weighting is applied according to IEC 61672-1:2013 standard.

        Returns
        -------
        NDArrayReal
            The A-weighted spectrum in decibels. Shape matches the input data shape:
            (channels, frequency_bins).
        """
        # Collect dB reference values from _channel_metadata
        weighted: NDArrayReal = librosa.A_weighting(frequencies=self.freqs, min_db=None)
        return self.dB + weighted

    @property
    def _n_channels(self) -> int:
        """
        Get the number of channels in the data.

        Returns
        -------
        int
            The number of channels in the N-octave band data.
        """
        return int(self._data.shape[-2])

    @property
    def freqs(self) -> NDArrayReal:
        """
        Get the center frequencies of each band in Hz.

        These frequencies are calculated based on the N-octave band parameters
        (n, G, fr) and the frequency bounds (fmin, fmax) according to
        IEC 61260-1:2014 standard for fractional octave band filters.

        Returns
        -------
        NDArrayReal
            Array of center frequencies for each frequency band.

        Raises
        ------
        ValueError
            If the center frequencies cannot be calculated or the result
            is not a numpy array.
        """
        _, freqs = _center_freq(
            fmax=self.fmax,
            fmin=self.fmin,
            n=self.n,
            G=self.G,
            fr=self.fr,
        )
        if isinstance(freqs, np.ndarray):
            return freqs
        else:
            raise ValueError("freqs is not numpy array.")

    def _binary_op(
        self: S,
        other: S | int | float | NDArrayReal | DaArray,
        op: Callable[[DaArray, Any], DaArray],
        symbol: str,
    ) -> S:
        """
        Binary operations are not currently supported for N-octave band data.

        Parameters
        ----------
        other : Union[S, int, float, NDArrayReal, DaArray]
            The right operand of the operation.
        op : callable
            Function to execute the operation.
        symbol : str
            String representation of the operation (e.g., '+', '-', '*', '/').

        Raises
        ------
        NotImplementedError
            Always raises this error as operations are not implemented
            for N-octave band data.
        """
        raise NotImplementedError(
            f"Operation {symbol} is not implemented for NOctFrame."
        )
        return self

    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        """
        Apply operations using lazy evaluation.
        """
        # Apply operations using lazy evaluation
        raise NotImplementedError(
            f"Operation {operation_name} is not implemented for NOctFrame."
        )
        return self

    def plot(
        self,
        plot_type: str = "noct",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        xlabel: str | None = None,
        ylabel: str | None = None,
        alpha: float = 1.0,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> Union["Axes", Iterator["Axes"]]:
        """
        Plot the N-octave band data using various visualization strategies.

        Supports standard plotting configurations for acoustic analysis,
        including decibel scales and A-weighting.

        Parameters
        ----------
        plot_type : str, default="noct"
            Type of plot to create. The default "noct" type creates a step plot
            suitable for displaying N-octave band data.
        ax : matplotlib.axes.Axes, optional
            Axes to plot on. If None, creates new axes.
        title : str, optional
            Title for the plot. If None, uses a default title with band specification.
        overlay : bool, default=False
            Whether to overlay all channels on a single plot (True)
            or create separate subplots for each channel (False).
        xlabel : str, optional
            Label for the x-axis. If None, uses default "Center frequency [Hz]".
        ylabel : str, optional
            Label for the y-axis. If None, uses default based on data type.
        alpha : float, default=1.0
            Transparency level for the plot lines (0.0 to 1.0).
        xlim : tuple[float, float], optional
            Limits for the x-axis as (min, max) tuple.
        ylim : tuple[float, float], optional
            Limits for the y-axis as (min, max) tuple.
        Aw : bool, default=False
            Whether to apply A-weighting to the data.
        **kwargs : dict
            Additional matplotlib Line2D parameters
            (e.g., color, linewidth, linestyle).

        Returns
        -------
        Union[Axes, Iterator[Axes]]
            The matplotlib axes containing the plot, or an iterator of axes
            for multi-plot outputs.

        Examples
        --------
        >>> noct = spectrum.noct(n=3)
        >>> # Basic 1/3-octave plot
        >>> noct.plot()
        >>> # Overlay with A-weighting
        >>> noct.plot(overlay=True, Aw=True)
        >>> # Custom styling
        >>> noct.plot(title="1/3-Octave Spectrum", color="blue", linewidth=2)
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        plot_strategy: PlotStrategy[NOctFrame] = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "overlay": overlay,
            "Aw": Aw,
            **kwargs,
        }
        if xlabel is not None:
            plot_kwargs["xlabel"] = xlabel
        if ylabel is not None:
            plot_kwargs["ylabel"] = ylabel
        if alpha != 1.0:
            plot_kwargs["alpha"] = alpha
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Get additional initialization arguments for NOctFrame.

        This internal method provides the additional initialization arguments
        required by NOctFrame beyond those required by BaseFrame. These include
        the N-octave band analysis parameters that define the frequency bands.

        Returns
        -------
        dict[str, Any]
            Additional initialization arguments specific to NOctFrame:
            - n: Number of bands per octave
            - G: Reference band number
            - fr: Reference frequency
            - fmin: Lower frequency bound
            - fmax: Upper frequency bound
        """
        return {
            "n": self.n,
            "G": self.G,
            "fr": self.fr,
            "fmin": self.fmin,
            "fmax": self.fmax,
        }

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """Get frequency index for DataFrame."""
        return pd.Index(self.freqs, name="frequency")
Attributes
n = n instance-attribute
G = G instance-attribute
fr = fr instance-attribute
fmin = fmin instance-attribute
fmax = fmax instance-attribute
dB property

Get the spectrum in decibels relative to each channel's reference value.

The reference value for each channel is specified in its metadata. A minimum value of -120 dB is enforced to avoid numerical issues.

Returns

NDArrayReal The spectrum in decibels. Shape matches the input data shape: (channels, frequency_bins).

dBA property

Get the A-weighted spectrum in decibels.

A-weighting applies a frequency-dependent weighting filter that approximates the human ear's response to different frequencies. This is particularly useful for analyzing noise and acoustic measurements as it provides a better correlation with perceived loudness.

The weighting is applied according to IEC 61672-1:2013 standard.

Returns

NDArrayReal The A-weighted spectrum in decibels. Shape matches the input data shape: (channels, frequency_bins).

freqs property

Get the center frequencies of each band in Hz.

These frequencies are calculated based on the N-octave band parameters (n, G, fr) and the frequency bounds (fmin, fmax) according to IEC 61260-1:2014 standard for fractional octave band filters.

Returns

NDArrayReal Array of center frequencies for each frequency band.

Raises

ValueError If the center frequencies cannot be calculated or the result is not a numpy array.

Functions
__init__(data, sampling_rate, fmin=0, fmax=0, n=3, G=10, fr=1000, label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)

Initialize a NOctFrame instance.

Sets up N-octave band analysis parameters and prepares the frame for storing band-filtered data. Data shape is validated to ensure compatibility with N-octave band analysis.

See class docstring for parameter descriptions.

Source code in wandas/frames/noct.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    fmin: float = 0,
    fmax: float = 0,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> None:
    """
    Initialize a NOctFrame instance.

    Sets up N-octave band analysis parameters and prepares the frame for
    storing band-filtered data. Data shape is validated to ensure compatibility
    with N-octave band analysis.

    See class docstring for parameter descriptions.
    """
    self.n = n
    self.G = G
    self.fr = fr
    self.fmin = fmin
    self.fmax = fmax
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
plot(plot_type='noct', ax=None, title=None, overlay=False, xlabel=None, ylabel=None, alpha=1.0, xlim=None, ylim=None, Aw=False, **kwargs)

Plot the N-octave band data using various visualization strategies.

Supports standard plotting configurations for acoustic analysis, including decibel scales and A-weighting.

Parameters

plot_type : str, default="noct" Type of plot to create. The default "noct" type creates a step plot suitable for displaying N-octave band data. ax : matplotlib.axes.Axes, optional Axes to plot on. If None, creates new axes. title : str, optional Title for the plot. If None, uses a default title with band specification. overlay : bool, default=False Whether to overlay all channels on a single plot (True) or create separate subplots for each channel (False). xlabel : str, optional Label for the x-axis. If None, uses default "Center frequency [Hz]". ylabel : str, optional Label for the y-axis. If None, uses default based on data type. alpha : float, default=1.0 Transparency level for the plot lines (0.0 to 1.0). xlim : tuple[float, float], optional Limits for the x-axis as (min, max) tuple. ylim : tuple[float, float], optional Limits for the y-axis as (min, max) tuple. Aw : bool, default=False Whether to apply A-weighting to the data. **kwargs : dict Additional matplotlib Line2D parameters (e.g., color, linewidth, linestyle).

Returns

Union[Axes, Iterator[Axes]] The matplotlib axes containing the plot, or an iterator of axes for multi-plot outputs.

Examples

noct = spectrum.noct(n=3)

Basic 1/3-octave plot

noct.plot()

Overlay with A-weighting

noct.plot(overlay=True, Aw=True)

Custom styling

noct.plot(title="1/3-Octave Spectrum", color="blue", linewidth=2)

Source code in wandas/frames/noct.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
def plot(
    self,
    plot_type: str = "noct",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    xlabel: str | None = None,
    ylabel: str | None = None,
    alpha: float = 1.0,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> Union["Axes", Iterator["Axes"]]:
    """
    Plot the N-octave band data using various visualization strategies.

    Supports standard plotting configurations for acoustic analysis,
    including decibel scales and A-weighting.

    Parameters
    ----------
    plot_type : str, default="noct"
        Type of plot to create. The default "noct" type creates a step plot
        suitable for displaying N-octave band data.
    ax : matplotlib.axes.Axes, optional
        Axes to plot on. If None, creates new axes.
    title : str, optional
        Title for the plot. If None, uses a default title with band specification.
    overlay : bool, default=False
        Whether to overlay all channels on a single plot (True)
        or create separate subplots for each channel (False).
    xlabel : str, optional
        Label for the x-axis. If None, uses default "Center frequency [Hz]".
    ylabel : str, optional
        Label for the y-axis. If None, uses default based on data type.
    alpha : float, default=1.0
        Transparency level for the plot lines (0.0 to 1.0).
    xlim : tuple[float, float], optional
        Limits for the x-axis as (min, max) tuple.
    ylim : tuple[float, float], optional
        Limits for the y-axis as (min, max) tuple.
    Aw : bool, default=False
        Whether to apply A-weighting to the data.
    **kwargs : dict
        Additional matplotlib Line2D parameters
        (e.g., color, linewidth, linestyle).

    Returns
    -------
    Union[Axes, Iterator[Axes]]
        The matplotlib axes containing the plot, or an iterator of axes
        for multi-plot outputs.

    Examples
    --------
    >>> noct = spectrum.noct(n=3)
    >>> # Basic 1/3-octave plot
    >>> noct.plot()
    >>> # Overlay with A-weighting
    >>> noct.plot(overlay=True, Aw=True)
    >>> # Custom styling
    >>> noct.plot(title="1/3-Octave Spectrum", color="blue", linewidth=2)
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    plot_strategy: PlotStrategy[NOctFrame] = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "overlay": overlay,
        "Aw": Aw,
        **kwargs,
    }
    if xlabel is not None:
        plot_kwargs["xlabel"] = xlabel
    if ylabel is not None:
        plot_kwargs["ylabel"] = ylabel
    if alpha != 1.0:
        plot_kwargs["alpha"] = alpha
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax

roughness

Roughness analysis frame for detailed psychoacoustic analysis.

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
RoughnessFrame

Bases: BaseFrame[NDArrayReal]

Frame for detailed roughness analysis with Bark-band information.

This frame contains specific roughness (R_spec) data organized by Bark frequency bands over time, calculated using the Daniel & Weber (1997) method.

The relationship between total roughness and specific roughness follows: R = 0.25 * sum(R_spec, axis=bark_bands)

Parameters

data : da.Array Specific roughness data with shape: - (n_bark_bands, n_time) for mono signals - (n_channels, n_bark_bands, n_time) for multi-channel signals where n_bark_bands is always 47. sampling_rate : float Sampling rate of the roughness time series in Hz. For overlap=0.5, this is approximately 10 Hz (100ms hop). For overlap=0.0, this is approximately 5 Hz (200ms hop). bark_axis : NDArrayReal Bark frequency axis with 47 values from 0.5 to 23.5 Bark. overlap : float Overlap coefficient used in the calculation (0.0 to 1.0). label : str, optional Frame label. Defaults to "roughness_spec". metadata : dict, optional Additional metadata. operation_history : list[dict], optional History of operations applied to this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel. previous : BaseFrame, optional Reference to the previous frame in the processing chain.

Attributes

bark_axis : NDArrayReal Frequency axis in Bark scale. n_bark_bands : int Number of Bark bands (always 47). n_time_points : int Number of time points. time : NDArrayReal Time axis based on sampling rate. overlap : float Overlap coefficient used (0.0 to 1.0).

Examples

Create a roughness frame from a signal:

import wandas as wd signal = wd.read_wav("motor.wav") roughness_spec = signal.roughness_dw_spec(overlap=0.5)

Plot Bark-Time heatmap

roughness_spec.plot()

Find dominant Bark band

dominant_idx = roughness_spec.data.mean(axis=1).argmax() dominant_bark = roughness_spec.bark_axis[dominant_idx] print(f"Dominant frequency: {dominant_bark:.1f} Bark")

Extract specific Bark band

bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0)) roughness_at_10bark = roughness_spec.data[bark_10_idx, :]

Notes

The Daniel & Weber (1997) roughness model calculates specific roughness for 47 critical bands (Bark scale) over time, then integrates them to produce the total roughness:

.. math:: R = 0.25 \sum_{i=1}^{47} R'_i

where R'_i is the specific roughness in the i-th Bark band.

References

.. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model". Acta Acustica united with Acustica, 83(1), 113-123.

Source code in wandas/frames/roughness.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
class RoughnessFrame(BaseFrame[NDArrayReal]):
    """
    Frame for detailed roughness analysis with Bark-band information.

    This frame contains specific roughness (R_spec) data organized by
    Bark frequency bands over time, calculated using the Daniel & Weber (1997)
    method.

    The relationship between total roughness and specific roughness follows:
    R = 0.25 * sum(R_spec, axis=bark_bands)

    Parameters
    ----------
    data : da.Array
        Specific roughness data with shape:
        - (n_bark_bands, n_time) for mono signals
        - (n_channels, n_bark_bands, n_time) for multi-channel signals
        where n_bark_bands is always 47.
    sampling_rate : float
        Sampling rate of the roughness time series in Hz.
        For overlap=0.5, this is approximately 10 Hz (100ms hop).
        For overlap=0.0, this is approximately 5 Hz (200ms hop).
    bark_axis : NDArrayReal
        Bark frequency axis with 47 values from 0.5 to 23.5 Bark.
    overlap : float
        Overlap coefficient used in the calculation (0.0 to 1.0).
    label : str, optional
        Frame label. Defaults to "roughness_spec".
    metadata : dict, optional
        Additional metadata.
    operation_history : list[dict], optional
        History of operations applied to this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel.
    previous : BaseFrame, optional
        Reference to the previous frame in the processing chain.

    Attributes
    ----------
    bark_axis : NDArrayReal
        Frequency axis in Bark scale.
    n_bark_bands : int
        Number of Bark bands (always 47).
    n_time_points : int
        Number of time points.
    time : NDArrayReal
        Time axis based on sampling rate.
    overlap : float
        Overlap coefficient used (0.0 to 1.0).

    Examples
    --------
    Create a roughness frame from a signal:

    >>> import wandas as wd
    >>> signal = wd.read_wav("motor.wav")
    >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
    >>>
    >>> # Plot Bark-Time heatmap
    >>> roughness_spec.plot()
    >>>
    >>> # Find dominant Bark band
    >>> dominant_idx = roughness_spec.data.mean(axis=1).argmax()
    >>> dominant_bark = roughness_spec.bark_axis[dominant_idx]
    >>> print(f"Dominant frequency: {dominant_bark:.1f} Bark")
    >>>
    >>> # Extract specific Bark band
    >>> bark_10_idx = np.argmin(np.abs(roughness_spec.bark_axis - 10.0))
    >>> roughness_at_10bark = roughness_spec.data[bark_10_idx, :]

    Notes
    -----
    The Daniel & Weber (1997) roughness model calculates specific roughness
    for 47 critical bands (Bark scale) over time, then integrates them to
    produce the total roughness:

    .. math::
        R = 0.25 \\sum_{i=1}^{47} R'_i

    where R'_i is the specific roughness in the i-th Bark band.

    References
    ----------
    .. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
           Implementation of an optimized model". Acta Acustica united with
           Acustica, 83(1), 113-123.
    """

    def __init__(
        self,
        data: da.Array,
        sampling_rate: float,
        bark_axis: NDArrayReal,
        overlap: float,
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> None:
        """Initialize a RoughnessFrame."""
        # Validate dimensions
        if data.ndim not in (2, 3):
            raise ValueError(
                f"Data must be 2D or 3D (mono or multi-channel), got {data.ndim}D"
            )

        # Validate Bark bands
        if data.shape[-2] != 47:
            raise ValueError(
                f"Expected 47 Bark bands, got {data.shape[-2]} "
                f"(data shape: {data.shape})"
            )

        if len(bark_axis) != 47:
            raise ValueError(f"bark_axis must have 47 elements, got {len(bark_axis)}")

        # Validate overlap
        if not 0.0 <= overlap <= 1.0:
            raise ValueError(f"overlap must be in [0.0, 1.0], got {overlap}")

        # Store Bark-specific attributes
        self._bark_axis = bark_axis
        self._overlap = overlap

        # Initialize base frame
        metadata = metadata or {}
        metadata["overlap"] = overlap

        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label or "roughness_spec",
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def data(self) -> NDArrayReal:
        """
        Returns the computed data without squeezing.

        For RoughnessFrame, even mono signals have 2D shape (47, n_time)
        so we don't squeeze the channel dimension.

        Returns
        -------
        NDArrayReal
            Computed data array.
        """
        return self.compute()

    @property
    def bark_axis(self) -> NDArrayReal:
        """
        Bark frequency axis.

        Returns
        -------
        NDArrayReal
            Array of 47 Bark values from 0.5 to 23.5 Bark.
        """
        return self._bark_axis

    @property
    def n_bark_bands(self) -> int:
        """
        Number of Bark bands.

        Returns
        -------
        int
            Always 47 for the Daniel & Weber model.
        """
        return 47

    @property
    def n_time_points(self) -> int:
        """
        Number of time points in the roughness time series.

        Returns
        -------
        int
            Number of time frames in the analysis.
        """
        return int(self._data.shape[-1])

    @property
    def time(self) -> NDArrayReal:
        """
        Time axis based on sampling rate.

        Returns
        -------
        NDArrayReal
            Time values in seconds for each frame.
        """
        return np.arange(self.n_time_points) / self.sampling_rate

    @property
    def overlap(self) -> float:
        """
        Overlap coefficient used in the calculation.

        Returns
        -------
        float
            Overlap value between 0.0 and 1.0.
        """
        return self._overlap

    @property
    def _n_channels(self) -> int:
        """
        Return the number of channels.

        Returns
        -------
        int
            Number of channels. For 2D data (mono), returns 1.
        """
        if self._data.ndim == 2:
            return 1
        return int(self._data.shape[0])

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Provide additional initialization arguments for RoughnessFrame.

        Returns
        -------
        dict
            Dictionary containing bark_axis and overlap
        """
        return {
            "bark_axis": self._bark_axis,
            "overlap": self._overlap,
        }

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """DataFrame index is not supported for RoughnessFrame."""
        raise NotImplementedError(
            "DataFrame index is not supported for RoughnessFrame."
        )

    def to_dataframe(self) -> "pd.DataFrame":
        """DataFrame conversion is not supported for RoughnessFrame.

        RoughnessFrame contains 3D data (channels, bark_bands, time_frames)
        which cannot be directly converted to a 2D DataFrame.

        Raises
        ------
        NotImplementedError
            Always raised as DataFrame conversion is not supported.
        """
        raise NotImplementedError(
            "DataFrame conversion is not supported for RoughnessFrame."
        )

    def _binary_op(
        self,
        other: Union["RoughnessFrame", int, float, NDArrayReal, da.Array],
        op: "Callable[[da.Array, Any], da.Array]",
        symbol: str,
    ) -> "RoughnessFrame":
        """
        Common implementation for binary operations.

        Parameters
        ----------
        other : RoughnessFrame, int, float, NDArrayReal, or da.Array
            Right operand for the operation.
        op : Callable
            Function to execute the operation.
        symbol : str
            Symbolic representation of the operation.

        Returns
        -------
        RoughnessFrame
            A new RoughnessFrame with the operation result.

        Raises
        ------
        ValueError
            If sampling rates don't match or shapes are incompatible.
        """
        logger.debug(f"Setting up {symbol} operation (lazy)")

        # Handle metadata and operation_history
        metadata = self.metadata.copy() if self.metadata else {}
        operation_history = (
            self.operation_history.copy() if self.operation_history else []
        )

        # Check if other is a RoughnessFrame
        if isinstance(other, RoughnessFrame):
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    f"Sampling rates do not match: {self.sampling_rate} vs "
                    f"{other.sampling_rate}"
                )

            if self._data.shape != other._data.shape:
                raise ValueError(
                    f"Shape mismatch: {self._data.shape} vs {other._data.shape}"
                )

            # Apply operation
            result_data = op(self._data, other._data)

            # Update operation history
            operation_history.append(
                {"name": f"binary_op_{symbol}", "params": {"other": "RoughnessFrame"}}
            )

        else:
            # Scalar or array operation
            if isinstance(other, np.ndarray):
                other = da.from_array(other, chunks=self._data.chunks)

            result_data = op(self._data, other)

            operation_history.append(
                {"name": f"binary_op_{symbol}", "params": {"other": str(type(other))}}
            )

        # Create new instance
        return RoughnessFrame(
            data=result_data,
            sampling_rate=self.sampling_rate,
            bark_axis=self._bark_axis,
            overlap=self._overlap,
            label=self.label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=self._channel_metadata,
            previous=self,
        )

    def _apply_operation_impl(
        self, operation_name: str, **params: Any
    ) -> "RoughnessFrame":
        """
        Implementation of operation application.

        Note: RoughnessFrame is typically a terminal node in processing chains.
        Most operations are not directly applicable to spectral roughness data.

        Parameters
        ----------
        operation_name : str
            Name of the operation to apply.
        **params : Any
            Operation parameters.

        Returns
        -------
        RoughnessFrame
            A new RoughnessFrame with the operation applied.

        Raises
        ------
        NotImplementedError
            As most operations are not applicable to roughness spectrograms.
        """
        raise NotImplementedError(
            f"Operation '{operation_name}' is not supported for RoughnessFrame. "
            "RoughnessFrame is typically a terminal node in the processing chain."
        )

    def plot(
        self,
        plot_type: str = "heatmap",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        cmap: str = "viridis",
        vmin: float | None = None,
        vmax: float | None = None,
        xlabel: str = "Time [s]",
        ylabel: str = "Frequency [Bark]",
        colorbar_label: str = "Specific Roughness [Asper/Bark]",
        **kwargs: Any,
    ) -> "Axes":
        """
        Plot Bark-Time-Roughness heatmap.

        For multi-channel signals, the mean across channels is plotted.

        Parameters
        ----------
        ax : Axes, optional
            Matplotlib axes to plot on. If None, a new figure is created.
        title : str, optional
            Plot title. If None, a default title is used.
        cmap : str, default="viridis"
            Colormap name for the heatmap.
        vmin, vmax : float, optional
            Color scale limits. If None, automatic scaling is used.
        xlabel : str, default="Time [s]"
            Label for the x-axis.
        ylabel : str, default="Frequency [Bark]"
            Label for the y-axis.
        colorbar_label : str, default="Specific Roughness [Asper/Bark]"
            Label for the colorbar.
        **kwargs : Any
            Additional keyword arguments passed to pcolormesh.

        Returns
        -------
        Axes
            The matplotlib axes object containing the plot.

        Examples
        --------
        >>> import wandas as wd
        >>> signal = wd.read_wav("motor.wav")
        >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
        >>> roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")
        """
        import matplotlib.pyplot as plt

        if ax is None:
            _, ax = plt.subplots(figsize=(10, 6))

        # Select data to plot (first channel for mono, mean for multi-channel)
        # self._data is Dask array, self.data is computed NumPy array
        computed_data = self.compute()

        if computed_data.ndim == 2:
            # Mono: (47, n_time)
            data_to_plot = computed_data
        else:
            # Multi-channel: (n_channels, 47, n_time) -> average to (47, n_time)
            data_to_plot = computed_data.mean(axis=0)

        # Create heatmap
        im = ax.pcolormesh(
            self.time,
            self.bark_axis,
            data_to_plot,
            shading="auto",
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            **kwargs,
        )

        # Labels and title
        ax.set_xlabel(xlabel)
        ax.set_ylabel(ylabel)
        if title is None:
            title = f"Roughness Spectrogram (overlap={self._overlap})"
        ax.set_title(title)

        # Colorbar
        plt.colorbar(im, ax=ax, label=colorbar_label)

        return ax
Attributes
data property

Returns the computed data without squeezing.

For RoughnessFrame, even mono signals have 2D shape (47, n_time) so we don't squeeze the channel dimension.

Returns

NDArrayReal Computed data array.

bark_axis property

Bark frequency axis.

Returns

NDArrayReal Array of 47 Bark values from 0.5 to 23.5 Bark.

n_bark_bands property

Number of Bark bands.

Returns

int Always 47 for the Daniel & Weber model.

n_time_points property

Number of time points in the roughness time series.

Returns

int Number of time frames in the analysis.

time property

Time axis based on sampling rate.

Returns

NDArrayReal Time values in seconds for each frame.

overlap property

Overlap coefficient used in the calculation.

Returns

float Overlap value between 0.0 and 1.0.

Functions
__init__(data, sampling_rate, bark_axis, overlap, label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)

Initialize a RoughnessFrame.

Source code in wandas/frames/roughness.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def __init__(
    self,
    data: da.Array,
    sampling_rate: float,
    bark_axis: NDArrayReal,
    overlap: float,
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> None:
    """Initialize a RoughnessFrame."""
    # Validate dimensions
    if data.ndim not in (2, 3):
        raise ValueError(
            f"Data must be 2D or 3D (mono or multi-channel), got {data.ndim}D"
        )

    # Validate Bark bands
    if data.shape[-2] != 47:
        raise ValueError(
            f"Expected 47 Bark bands, got {data.shape[-2]} "
            f"(data shape: {data.shape})"
        )

    if len(bark_axis) != 47:
        raise ValueError(f"bark_axis must have 47 elements, got {len(bark_axis)}")

    # Validate overlap
    if not 0.0 <= overlap <= 1.0:
        raise ValueError(f"overlap must be in [0.0, 1.0], got {overlap}")

    # Store Bark-specific attributes
    self._bark_axis = bark_axis
    self._overlap = overlap

    # Initialize base frame
    metadata = metadata or {}
    metadata["overlap"] = overlap

    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label or "roughness_spec",
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
to_dataframe()

DataFrame conversion is not supported for RoughnessFrame.

RoughnessFrame contains 3D data (channels, bark_bands, time_frames) which cannot be directly converted to a 2D DataFrame.

Raises

NotImplementedError Always raised as DataFrame conversion is not supported.

Source code in wandas/frames/roughness.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def to_dataframe(self) -> "pd.DataFrame":
    """DataFrame conversion is not supported for RoughnessFrame.

    RoughnessFrame contains 3D data (channels, bark_bands, time_frames)
    which cannot be directly converted to a 2D DataFrame.

    Raises
    ------
    NotImplementedError
        Always raised as DataFrame conversion is not supported.
    """
    raise NotImplementedError(
        "DataFrame conversion is not supported for RoughnessFrame."
    )
plot(plot_type='heatmap', ax=None, title=None, cmap='viridis', vmin=None, vmax=None, xlabel='Time [s]', ylabel='Frequency [Bark]', colorbar_label='Specific Roughness [Asper/Bark]', **kwargs)

Plot Bark-Time-Roughness heatmap.

For multi-channel signals, the mean across channels is plotted.

Parameters

ax : Axes, optional Matplotlib axes to plot on. If None, a new figure is created. title : str, optional Plot title. If None, a default title is used. cmap : str, default="viridis" Colormap name for the heatmap. vmin, vmax : float, optional Color scale limits. If None, automatic scaling is used. xlabel : str, default="Time [s]" Label for the x-axis. ylabel : str, default="Frequency [Bark]" Label for the y-axis. colorbar_label : str, default="Specific Roughness [Asper/Bark]" Label for the colorbar. **kwargs : Any Additional keyword arguments passed to pcolormesh.

Returns

Axes The matplotlib axes object containing the plot.

Examples

import wandas as wd signal = wd.read_wav("motor.wav") roughness_spec = signal.roughness_dw_spec(overlap=0.5) roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")

Source code in wandas/frames/roughness.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
def plot(
    self,
    plot_type: str = "heatmap",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    cmap: str = "viridis",
    vmin: float | None = None,
    vmax: float | None = None,
    xlabel: str = "Time [s]",
    ylabel: str = "Frequency [Bark]",
    colorbar_label: str = "Specific Roughness [Asper/Bark]",
    **kwargs: Any,
) -> "Axes":
    """
    Plot Bark-Time-Roughness heatmap.

    For multi-channel signals, the mean across channels is plotted.

    Parameters
    ----------
    ax : Axes, optional
        Matplotlib axes to plot on. If None, a new figure is created.
    title : str, optional
        Plot title. If None, a default title is used.
    cmap : str, default="viridis"
        Colormap name for the heatmap.
    vmin, vmax : float, optional
        Color scale limits. If None, automatic scaling is used.
    xlabel : str, default="Time [s]"
        Label for the x-axis.
    ylabel : str, default="Frequency [Bark]"
        Label for the y-axis.
    colorbar_label : str, default="Specific Roughness [Asper/Bark]"
        Label for the colorbar.
    **kwargs : Any
        Additional keyword arguments passed to pcolormesh.

    Returns
    -------
    Axes
        The matplotlib axes object containing the plot.

    Examples
    --------
    >>> import wandas as wd
    >>> signal = wd.read_wav("motor.wav")
    >>> roughness_spec = signal.roughness_dw_spec(overlap=0.5)
    >>> roughness_spec.plot(cmap="hot", title="Motor Roughness Analysis")
    """
    import matplotlib.pyplot as plt

    if ax is None:
        _, ax = plt.subplots(figsize=(10, 6))

    # Select data to plot (first channel for mono, mean for multi-channel)
    # self._data is Dask array, self.data is computed NumPy array
    computed_data = self.compute()

    if computed_data.ndim == 2:
        # Mono: (47, n_time)
        data_to_plot = computed_data
    else:
        # Multi-channel: (n_channels, 47, n_time) -> average to (47, n_time)
        data_to_plot = computed_data.mean(axis=0)

    # Create heatmap
    im = ax.pcolormesh(
        self.time,
        self.bark_axis,
        data_to_plot,
        shading="auto",
        cmap=cmap,
        vmin=vmin,
        vmax=vmax,
        **kwargs,
    )

    # Labels and title
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    if title is None:
        title = f"Roughness Spectrogram (overlap={self._overlap})"
    ax.set_title(title)

    # Colorbar
    plt.colorbar(im, ax=ax, label=colorbar_label)

    return ax

spectral

Attributes
dask_delayed = dask.delayed module-attribute
da_from_delayed = da.from_delayed module-attribute
da_from_array = da.from_array module-attribute
logger = logging.getLogger(__name__) module-attribute
S = TypeVar('S', bound='BaseFrame[Any]') module-attribute
Classes
SpectralFrame

Bases: BaseFrame[NDArrayComplex]

Class for handling frequency-domain signal data.

This class represents spectral data, providing methods for spectral analysis, manipulation, and visualization. It handles complex-valued frequency domain data obtained through operations like FFT.

Parameters

data : DaArray The spectral data. Must be a dask array with shape: - (channels, frequency_bins) for multi-channel data - (frequency_bins,) for single-channel data, which will be reshaped to (1, frequency_bins) sampling_rate : float The sampling rate of the original time-domain signal in Hz. n_fft : int The FFT size used to generate this spectral data. window : str, default="hann" The window function used in the FFT. label : str, optional A label for the frame. metadata : dict, optional Additional metadata for the frame. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel in the frame. previous : BaseFrame, optional The frame that this frame was derived from.

Attributes

magnitude : NDArrayReal The magnitude spectrum of the data. phase : NDArrayReal The phase spectrum in radians. unwrapped_phase : NDArrayReal The unwrapped phase spectrum in radians. power : NDArrayReal The power spectrum (magnitude squared). dB : NDArrayReal The spectrum in decibels relative to channel reference values. dBA : NDArrayReal The A-weighted spectrum in decibels. freqs : NDArrayReal The frequency axis values in Hz.

Examples

Create a SpectralFrame from FFT:

signal = ChannelFrame.from_numpy(data, sampling_rate=44100) spectrum = signal.fft(n_fft=2048)

Plot the magnitude spectrum:

spectrum.plot()

Perform binary operations:

scaled = spectrum * 2.0 summed = spectrum1 + spectrum2 # Must have matching sampling rates

Convert back to time domain:

time_signal = spectrum.ifft()

Notes
  • All operations are performed lazily using dask arrays for efficient memory usage.
  • Binary operations (+, -, *, /) can be performed between SpectralFrames or with scalar values.
  • The class maintains the processing history and metadata through all operations.
Source code in wandas/frames/spectral.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
class SpectralFrame(BaseFrame[NDArrayComplex]):
    """
    Class for handling frequency-domain signal data.

    This class represents spectral data, providing methods for spectral analysis,
    manipulation, and visualization. It handles complex-valued frequency domain data
    obtained through operations like FFT.

    Parameters
    ----------
    data : DaArray
        The spectral data. Must be a dask array with shape:
        - (channels, frequency_bins) for multi-channel data
        - (frequency_bins,) for single-channel data, which will be
          reshaped to (1, frequency_bins)
    sampling_rate : float
        The sampling rate of the original time-domain signal in Hz.
    n_fft : int
        The FFT size used to generate this spectral data.
    window : str, default="hann"
        The window function used in the FFT.
    label : str, optional
        A label for the frame.
    metadata : dict, optional
        Additional metadata for the frame.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel in the frame.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

    Attributes
    ----------
    magnitude : NDArrayReal
        The magnitude spectrum of the data.
    phase : NDArrayReal
        The phase spectrum in radians.
    unwrapped_phase : NDArrayReal
        The unwrapped phase spectrum in radians.
    power : NDArrayReal
        The power spectrum (magnitude squared).
    dB : NDArrayReal
        The spectrum in decibels relative to channel reference values.
    dBA : NDArrayReal
        The A-weighted spectrum in decibels.
    freqs : NDArrayReal
        The frequency axis values in Hz.

    Examples
    --------
    Create a SpectralFrame from FFT:
    >>> signal = ChannelFrame.from_numpy(data, sampling_rate=44100)
    >>> spectrum = signal.fft(n_fft=2048)

    Plot the magnitude spectrum:
    >>> spectrum.plot()

    Perform binary operations:
    >>> scaled = spectrum * 2.0
    >>> summed = spectrum1 + spectrum2  # Must have matching sampling rates

    Convert back to time domain:
    >>> time_signal = spectrum.ifft()

    Notes
    -----
    - All operations are performed lazily using dask arrays for efficient memory usage.
    - Binary operations (+, -, *, /) can be performed between SpectralFrames or with
      scalar values.
    - The class maintains the processing history and metadata through all operations.
    """

    n_fft: int
    window: str

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        n_fft: int,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: BaseFrame[Any] | None = None,
    ) -> None:
        if data.ndim == 1:
            data = data.reshape(1, -1)
        elif data.ndim > 2:
            raise ValueError(
                f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}"
            )
        self.n_fft = n_fft
        self.window = window
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def magnitude(self) -> NDArrayReal:
        """
        Get the magnitude spectrum.

        Returns
        -------
        NDArrayReal
            The absolute values of the complex spectrum.
        """
        return np.abs(self.data)

    @property
    def phase(self) -> NDArrayReal:
        """
        Get the phase spectrum.

        Returns
        -------
        NDArrayReal
            The phase angles of the complex spectrum in radians.
        """
        return np.angle(self.data)

    @property
    def unwrapped_phase(self) -> NDArrayReal:
        """
        Get the unwrapped phase spectrum.

        The unwrapped phase removes discontinuities of 2π radians, providing
        continuous phase values across frequency bins.

        Returns
        -------
        NDArrayReal
            The unwrapped phase angles of the complex spectrum in radians.
        """
        return np.unwrap(np.angle(self.data))

    @property
    def power(self) -> NDArrayReal:
        """
        Get the power spectrum.

        Returns
        -------
        NDArrayReal
            The squared magnitude spectrum.
        """
        return self.magnitude**2

    @property
    def dB(self) -> NDArrayReal:  # noqa: N802
        """
        Get the spectrum in decibels.

        The reference values are taken from channel metadata. If no reference
        is specified, uses 1.0.

        Returns
        -------
        NDArrayReal
            The spectrum in dB relative to channel references.
        """
        mag: NDArrayReal = self.magnitude
        ref_values: NDArrayReal = np.array([ch.ref for ch in self._channel_metadata])
        level: NDArrayReal = 20 * np.log10(
            np.maximum(mag / ref_values[:, np.newaxis], 1e-12)
        )

        return level

    @property
    def dBA(self) -> NDArrayReal:  # noqa: N802
        """
        Get the A-weighted spectrum in decibels.

        Applies A-weighting filter to the spectrum for better correlation with
        perceived loudness.

        Returns
        -------
        NDArrayReal
            The A-weighted spectrum in dB.
        """
        weighted: NDArrayReal = librosa.A_weighting(frequencies=self.freqs, min_db=None)
        return self.dB + weighted

    @property
    def _n_channels(self) -> int:
        """
        Get the number of channels in the data.

        Returns
        -------
        int
            The number of channels.
        """
        return int(self._data.shape[-2])

    @property
    def freqs(self) -> NDArrayReal:
        """
        Get the frequency axis values in Hz.

        Returns
        -------
        NDArrayReal
            Array of frequency values corresponding to each frequency bin.
        """
        return np.fft.rfftfreq(self.n_fft, 1.0 / self.sampling_rate)

    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        """
        Implementation of operation application for spectral data.

        This internal method handles the application of various operations to
        spectral data, maintaining lazy evaluation through dask.

        Parameters
        ----------
        operation_name : str
            Name of the operation to apply.
        **params : Any
            Parameters for the operation.

        Returns
        -------
        S
            A new instance with the operation applied.
        """
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
        from ..processing import create_operation

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)

        # Apply processing to data
        processed_data = operation.process(self._data)

        # Update metadata
        operation_metadata = {"operation": operation_name, "params": params}
        new_history = self.operation_history.copy()
        new_history.append(operation_metadata)
        new_metadata = {**self.metadata}
        new_metadata[operation_name] = params

        logger.debug(
            f"Created new ChannelFrame with operation {operation_name} added to graph"
        )
        return self._create_new_instance(
            data=processed_data,
            metadata=new_metadata,
            operation_history=new_history,
        )

    def _binary_op(
        self,
        other: (
            SpectralFrame
            | int
            | float
            | complex
            | NDArrayComplex
            | NDArrayReal
            | DaArray
        ),
        op: Callable[[DaArray, Any], DaArray],
        symbol: str,
    ) -> SpectralFrame:
        """
        Common implementation for binary operations.

        This method handles binary operations between SpectralFrames and various types
        of operands, maintaining lazy evaluation through dask arrays.

        Parameters
        ----------
        other : Union[SpectralFrame, int, float, complex,
                        NDArrayComplex, NDArrayReal, DaArray]
            The right operand of the operation.
        op : callable
            Function to execute the operation (e.g., lambda a, b: a + b)
        symbol : str
            String representation of the operation (e.g., '+')

        Returns
        -------
        SpectralFrame
            A new SpectralFrame containing the result of the operation.

        Raises
        ------
        ValueError
            If attempting to operate with a SpectralFrame
            with a different sampling rate.
        """
        logger.debug(f"Setting up {symbol} operation (lazy)")

        # Handle potentially None metadata and operation_history
        metadata = {}
        if self.metadata is not None:
            metadata = self.metadata.copy()

        operation_history = []
        if self.operation_history is not None:
            operation_history = self.operation_history.copy()

        # Check if other is a ChannelFrame - improved type checking
        if isinstance(other, SpectralFrame):
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    "Sampling rates do not match. Cannot perform operation."
                )

            # Directly operate on dask arrays (maintaining lazy execution)
            result_data = op(self._data, other._data)

            # Combine channel metadata
            merged_channel_metadata = []
            for self_ch, other_ch in zip(
                self._channel_metadata, other._channel_metadata
            ):
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch['label']} {symbol} {other_ch['label']})"
                merged_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other.label})

            return SpectralFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                n_fft=self.n_fft,
                window=self.window,
                label=f"({self.label} {symbol} {other.label})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=merged_channel_metadata,
                previous=self,
            )

        # Operation with scalar, NumPy array, or other types
        else:
            # Apply operation directly to dask array (maintaining lazy execution)
            result_data = op(self._data, other)

            # String representation of operand for display
            if isinstance(other, int | float):
                other_str = str(other)
            elif isinstance(other, complex):
                other_str = f"complex({other.real}, {other.imag})"
            elif isinstance(other, np.ndarray):
                other_str = f"ndarray{other.shape}"
            elif hasattr(other, "shape"):  # Check for dask.array.Array
                other_str = f"dask.array{other.shape}"
            else:
                other_str = str(type(other).__name__)

            # Update channel metadata
            updated_channel_metadata: list[ChannelMetadata] = []
            for self_ch in self._channel_metadata:
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch.label} {symbol} {other_str})"
                updated_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other_str})

            return SpectralFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                n_fft=self.n_fft,
                window=self.window,
                label=f"({self.label} {symbol} {other_str})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=updated_channel_metadata,
            )

    def plot(
        self,
        plot_type: str = "frequency",
        ax: Axes | None = None,
        title: str | None = None,
        overlay: bool = False,
        xlabel: str | None = None,
        ylabel: str | None = None,
        alpha: float = 1.0,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """
        Plot the spectral data using various visualization strategies.

        Parameters
        ----------
        plot_type : str, default="frequency"
            Type of plot to create. Options include:
            - "frequency": Standard frequency plot
            - "matrix": Matrix plot for comparing channels
            - Other types as defined by available plot strategies
        ax : matplotlib.axes.Axes, optional
            Axes to plot on. If None, creates new axes.
        title : str, optional
            Title for the plot. If None, uses the frame label.
        overlay : bool, default=False
            Whether to overlay all channels on a single plot (True)
            or create separate subplots for each channel (False).
        xlabel : str, optional
            Label for the x-axis. If None, uses default "Frequency [Hz]".
        ylabel : str, optional
            Label for the y-axis. If None, uses default based on data type.
        alpha : float, default=1.0
            Transparency level for the plot lines (0.0 to 1.0).
        xlim : tuple[float, float], optional
            Limits for the x-axis as (min, max) tuple.
        ylim : tuple[float, float], optional
            Limits for the y-axis as (min, max) tuple.
        Aw : bool, default=False
            Whether to apply A-weighting to the data.
        **kwargs : dict
            Additional matplotlib Line2D parameters
            (e.g., color, linewidth, linestyle).

        Returns
        -------
        Union[Axes, Iterator[Axes]]
            The matplotlib axes containing the plot, or an iterator of axes
            for multi-plot outputs.

        Examples
        --------
        >>> spectrum = cf.fft()
        >>> # Basic frequency plot
        >>> spectrum.plot()
        >>> # Overlay with A-weighting
        >>> spectrum.plot(overlay=True, Aw=True)
        >>> # Custom styling
        >>> spectrum.plot(title="Frequency Spectrum", color="red", linewidth=2)
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "overlay": overlay,
            "Aw": Aw,
            **kwargs,
        }
        if xlabel is not None:
            plot_kwargs["xlabel"] = xlabel
        if ylabel is not None:
            plot_kwargs["ylabel"] = ylabel
        if alpha != 1.0:
            plot_kwargs["alpha"] = alpha
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # Execute plot
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def ifft(self) -> ChannelFrame:
        """
        Compute the Inverse Fast Fourier Transform (IFFT) to return to time domain.

        This method transforms the frequency-domain data back to the time domain using
        the inverse FFT operation. The window function used in the forward FFT is
        taken into account to ensure proper reconstruction.

        Returns
        -------
        ChannelFrame
            A new ChannelFrame containing the time-domain signal.
        """
        from ..processing import IFFT, create_operation
        from .channel import ChannelFrame

        params = {"n_fft": self.n_fft, "window": self.window}
        operation_name = "ifft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("IFFT", operation)
        # Apply processing to data
        time_series = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        # Create new instance
        return ChannelFrame(
            data=time_series,
            sampling_rate=self.sampling_rate,
            label=f"ifft({self.label})",
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
        )

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Provide additional initialization arguments required for SpectralFrame.

        Returns
        -------
        dict[str, Any]
            Additional initialization arguments for SpectralFrame.
        """
        return {
            "n_fft": self.n_fft,
            "window": self.window,
        }

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> pd.Index[Any]:
        """Get frequency index for DataFrame."""
        return pd.Index(self.freqs, name="frequency")

    def noct_synthesis(
        self,
        fmin: float,
        fmax: float,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ) -> NOctFrame:
        """
        Synthesize N-octave band spectrum.

        This method combines frequency components into N-octave bands according to
        standard acoustical band definitions. This is commonly used in noise and
        vibration analysis.

        Parameters
        ----------
        fmin : float
            Lower frequency bound in Hz.
        fmax : float
            Upper frequency bound in Hz.
        n : int, default=3
            Number of bands per octave (e.g., 3 for third-octave bands).
        G : int, default=10
            Reference band number.
        fr : int, default=1000
            Reference frequency in Hz.

        Returns
        -------
        NOctFrame
            A new NOctFrame containing the N-octave band spectrum.

        Raises
        ------
        ValueError
            If the sampling rate is not 48000 Hz, which is required for this operation.
        """
        if self.sampling_rate != 48000:
            raise ValueError(
                "noct_synthesis can only be used with a sampling rate of 48000 Hz."
            )
        from ..processing import NOctSynthesis
        from .noct import NOctFrame

        params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
        operation_name = "noct_synthesis"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
        from ..processing import create_operation

        # Create operation instance
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("NOctSynthesis", operation)
        # Apply processing to data
        spectrum_data = operation.process(self._data)

        logger.debug(
            f"Created new SpectralFrame with operation {operation_name} added to graph"
        )

        return NOctFrame(
            data=spectrum_data,
            sampling_rate=self.sampling_rate,
            fmin=fmin,
            fmax=fmax,
            n=n,
            G=G,
            fr=fr,
            label=f"1/{n}Oct of {self.label}",
            metadata={**self.metadata, **params},
            operation_history=[
                *self.operation_history,
                {
                    "operation": "noct_synthesis",
                    "params": params,
                },
            ],
            channel_metadata=self._channel_metadata,
            previous=self,
        )

    def plot_matrix(
        self,
        plot_type: str = "matrix",
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """
        Plot channel relationships in matrix format.

        This method creates a matrix plot showing relationships between channels,
        such as coherence, transfer functions, or cross-spectral density.

        Parameters
        ----------
        plot_type : str, default="matrix"
            Type of matrix plot to create.
        **kwargs : dict
            Additional plot parameters:
            - vmin, vmax: Color scale limits
            - cmap: Colormap name
            - title: Plot title

        Returns
        -------
        Union[Axes, Iterator[Axes]]
            The matplotlib axes containing the plot.
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # Get plot strategy
        plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

        # Execute plot
        _ax = plot_strategy.plot(self, **kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def info(self) -> None:
        """Display comprehensive information about the SpectralFrame.

        This method prints a summary of the frame's properties including:
        - Number of channels
        - Sampling rate
        - FFT size
        - Frequency range
        - Number of frequency bins
        - Frequency resolution (ΔF)
        - Channel labels

        This is a convenience method to view all key properties at once,
        similar to pandas DataFrame.info().

        Examples
        --------
        >>> spectrum = cf.fft()
        >>> spectrum.info()
        SpectralFrame Information:
          Channels: 2
          Sampling rate: 44100 Hz
          FFT size: 2048
          Frequency range: 0.0 - 22050.0 Hz
          Frequency bins: 1025
          Frequency resolution (ΔF): 21.5 Hz
          Channel labels: ['ch0', 'ch1']
          Operations Applied: 1
        """
        # Calculate frequency resolution (ΔF)
        delta_f = self.sampling_rate / self.n_fft

        print("SpectralFrame Information:")
        print(f"  Channels: {self.n_channels}")
        print(f"  Sampling rate: {self.sampling_rate} Hz")
        print(f"  FFT size: {self.n_fft}")
        print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
        print(f"  Frequency bins: {len(self.freqs)}")
        print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
        print(f"  Channel labels: {self.labels}")
        self._print_operation_history()
Attributes
n_fft = n_fft instance-attribute
window = window instance-attribute
magnitude property

Get the magnitude spectrum.

Returns

NDArrayReal The absolute values of the complex spectrum.

phase property

Get the phase spectrum.

Returns

NDArrayReal The phase angles of the complex spectrum in radians.

unwrapped_phase property

Get the unwrapped phase spectrum.

The unwrapped phase removes discontinuities of 2π radians, providing continuous phase values across frequency bins.

Returns

NDArrayReal The unwrapped phase angles of the complex spectrum in radians.

power property

Get the power spectrum.

Returns

NDArrayReal The squared magnitude spectrum.

dB property

Get the spectrum in decibels.

The reference values are taken from channel metadata. If no reference is specified, uses 1.0.

Returns

NDArrayReal The spectrum in dB relative to channel references.

dBA property

Get the A-weighted spectrum in decibels.

Applies A-weighting filter to the spectrum for better correlation with perceived loudness.

Returns

NDArrayReal The A-weighted spectrum in dB.

freqs property

Get the frequency axis values in Hz.

Returns

NDArrayReal Array of frequency values corresponding to each frequency bin.

Functions
__init__(data, sampling_rate, n_fft, window='hann', label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)
Source code in wandas/frames/spectral.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    n_fft: int,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: BaseFrame[Any] | None = None,
) -> None:
    if data.ndim == 1:
        data = data.reshape(1, -1)
    elif data.ndim > 2:
        raise ValueError(
            f"Data must be 1-dimensional or 2-dimensional. Shape: {data.shape}"
        )
    self.n_fft = n_fft
    self.window = window
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
plot(plot_type='frequency', ax=None, title=None, overlay=False, xlabel=None, ylabel=None, alpha=1.0, xlim=None, ylim=None, Aw=False, **kwargs)

Plot the spectral data using various visualization strategies.

Parameters

plot_type : str, default="frequency" Type of plot to create. Options include: - "frequency": Standard frequency plot - "matrix": Matrix plot for comparing channels - Other types as defined by available plot strategies ax : matplotlib.axes.Axes, optional Axes to plot on. If None, creates new axes. title : str, optional Title for the plot. If None, uses the frame label. overlay : bool, default=False Whether to overlay all channels on a single plot (True) or create separate subplots for each channel (False). xlabel : str, optional Label for the x-axis. If None, uses default "Frequency [Hz]". ylabel : str, optional Label for the y-axis. If None, uses default based on data type. alpha : float, default=1.0 Transparency level for the plot lines (0.0 to 1.0). xlim : tuple[float, float], optional Limits for the x-axis as (min, max) tuple. ylim : tuple[float, float], optional Limits for the y-axis as (min, max) tuple. Aw : bool, default=False Whether to apply A-weighting to the data. **kwargs : dict Additional matplotlib Line2D parameters (e.g., color, linewidth, linestyle).

Returns

Union[Axes, Iterator[Axes]] The matplotlib axes containing the plot, or an iterator of axes for multi-plot outputs.

Examples

spectrum = cf.fft()

Basic frequency plot

spectrum.plot()

Overlay with A-weighting

spectrum.plot(overlay=True, Aw=True)

Custom styling

spectrum.plot(title="Frequency Spectrum", color="red", linewidth=2)

Source code in wandas/frames/spectral.py
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
def plot(
    self,
    plot_type: str = "frequency",
    ax: Axes | None = None,
    title: str | None = None,
    overlay: bool = False,
    xlabel: str | None = None,
    ylabel: str | None = None,
    alpha: float = 1.0,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """
    Plot the spectral data using various visualization strategies.

    Parameters
    ----------
    plot_type : str, default="frequency"
        Type of plot to create. Options include:
        - "frequency": Standard frequency plot
        - "matrix": Matrix plot for comparing channels
        - Other types as defined by available plot strategies
    ax : matplotlib.axes.Axes, optional
        Axes to plot on. If None, creates new axes.
    title : str, optional
        Title for the plot. If None, uses the frame label.
    overlay : bool, default=False
        Whether to overlay all channels on a single plot (True)
        or create separate subplots for each channel (False).
    xlabel : str, optional
        Label for the x-axis. If None, uses default "Frequency [Hz]".
    ylabel : str, optional
        Label for the y-axis. If None, uses default based on data type.
    alpha : float, default=1.0
        Transparency level for the plot lines (0.0 to 1.0).
    xlim : tuple[float, float], optional
        Limits for the x-axis as (min, max) tuple.
    ylim : tuple[float, float], optional
        Limits for the y-axis as (min, max) tuple.
    Aw : bool, default=False
        Whether to apply A-weighting to the data.
    **kwargs : dict
        Additional matplotlib Line2D parameters
        (e.g., color, linewidth, linestyle).

    Returns
    -------
    Union[Axes, Iterator[Axes]]
        The matplotlib axes containing the plot, or an iterator of axes
        for multi-plot outputs.

    Examples
    --------
    >>> spectrum = cf.fft()
    >>> # Basic frequency plot
    >>> spectrum.plot()
    >>> # Overlay with A-weighting
    >>> spectrum.plot(overlay=True, Aw=True)
    >>> # Custom styling
    >>> spectrum.plot(title="Frequency Spectrum", color="red", linewidth=2)
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "overlay": overlay,
        "Aw": Aw,
        **kwargs,
    }
    if xlabel is not None:
        plot_kwargs["xlabel"] = xlabel
    if ylabel is not None:
        plot_kwargs["ylabel"] = ylabel
    if alpha != 1.0:
        plot_kwargs["alpha"] = alpha
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # Execute plot
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax
ifft()

Compute the Inverse Fast Fourier Transform (IFFT) to return to time domain.

This method transforms the frequency-domain data back to the time domain using the inverse FFT operation. The window function used in the forward FFT is taken into account to ensure proper reconstruction.

Returns

ChannelFrame A new ChannelFrame containing the time-domain signal.

Source code in wandas/frames/spectral.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def ifft(self) -> ChannelFrame:
    """
    Compute the Inverse Fast Fourier Transform (IFFT) to return to time domain.

    This method transforms the frequency-domain data back to the time domain using
    the inverse FFT operation. The window function used in the forward FFT is
    taken into account to ensure proper reconstruction.

    Returns
    -------
    ChannelFrame
        A new ChannelFrame containing the time-domain signal.
    """
    from ..processing import IFFT, create_operation
    from .channel import ChannelFrame

    params = {"n_fft": self.n_fft, "window": self.window}
    operation_name = "ifft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("IFFT", operation)
    # Apply processing to data
    time_series = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    # Create new instance
    return ChannelFrame(
        data=time_series,
        sampling_rate=self.sampling_rate,
        label=f"ifft({self.label})",
        metadata=self.metadata,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
    )
noct_synthesis(fmin, fmax, n=3, G=10, fr=1000)

Synthesize N-octave band spectrum.

This method combines frequency components into N-octave bands according to standard acoustical band definitions. This is commonly used in noise and vibration analysis.

Parameters

fmin : float Lower frequency bound in Hz. fmax : float Upper frequency bound in Hz. n : int, default=3 Number of bands per octave (e.g., 3 for third-octave bands). G : int, default=10 Reference band number. fr : int, default=1000 Reference frequency in Hz.

Returns

NOctFrame A new NOctFrame containing the N-octave band spectrum.

Raises

ValueError If the sampling rate is not 48000 Hz, which is required for this operation.

Source code in wandas/frames/spectral.py
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
def noct_synthesis(
    self,
    fmin: float,
    fmax: float,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
) -> NOctFrame:
    """
    Synthesize N-octave band spectrum.

    This method combines frequency components into N-octave bands according to
    standard acoustical band definitions. This is commonly used in noise and
    vibration analysis.

    Parameters
    ----------
    fmin : float
        Lower frequency bound in Hz.
    fmax : float
        Upper frequency bound in Hz.
    n : int, default=3
        Number of bands per octave (e.g., 3 for third-octave bands).
    G : int, default=10
        Reference band number.
    fr : int, default=1000
        Reference frequency in Hz.

    Returns
    -------
    NOctFrame
        A new NOctFrame containing the N-octave band spectrum.

    Raises
    ------
    ValueError
        If the sampling rate is not 48000 Hz, which is required for this operation.
    """
    if self.sampling_rate != 48000:
        raise ValueError(
            "noct_synthesis can only be used with a sampling rate of 48000 Hz."
        )
    from ..processing import NOctSynthesis
    from .noct import NOctFrame

    params = {"fmin": fmin, "fmax": fmax, "n": n, "G": G, "fr": fr}
    operation_name = "noct_synthesis"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
    from ..processing import create_operation

    # Create operation instance
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("NOctSynthesis", operation)
    # Apply processing to data
    spectrum_data = operation.process(self._data)

    logger.debug(
        f"Created new SpectralFrame with operation {operation_name} added to graph"
    )

    return NOctFrame(
        data=spectrum_data,
        sampling_rate=self.sampling_rate,
        fmin=fmin,
        fmax=fmax,
        n=n,
        G=G,
        fr=fr,
        label=f"1/{n}Oct of {self.label}",
        metadata={**self.metadata, **params},
        operation_history=[
            *self.operation_history,
            {
                "operation": "noct_synthesis",
                "params": params,
            },
        ],
        channel_metadata=self._channel_metadata,
        previous=self,
    )
plot_matrix(plot_type='matrix', **kwargs)

Plot channel relationships in matrix format.

This method creates a matrix plot showing relationships between channels, such as coherence, transfer functions, or cross-spectral density.

Parameters

plot_type : str, default="matrix" Type of matrix plot to create. **kwargs : dict Additional plot parameters: - vmin, vmax: Color scale limits - cmap: Colormap name - title: Plot title

Returns

Union[Axes, Iterator[Axes]] The matplotlib axes containing the plot.

Source code in wandas/frames/spectral.py
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
def plot_matrix(
    self,
    plot_type: str = "matrix",
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """
    Plot channel relationships in matrix format.

    This method creates a matrix plot showing relationships between channels,
    such as coherence, transfer functions, or cross-spectral density.

    Parameters
    ----------
    plot_type : str, default="matrix"
        Type of matrix plot to create.
    **kwargs : dict
        Additional plot parameters:
        - vmin, vmax: Color scale limits
        - cmap: Colormap name
        - title: Plot title

    Returns
    -------
    Union[Axes, Iterator[Axes]]
        The matplotlib axes containing the plot.
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # Get plot strategy
    plot_strategy: PlotStrategy[SpectralFrame] = create_operation(plot_type)

    # Execute plot
    _ax = plot_strategy.plot(self, **kwargs)

    logger.debug("Plot rendering complete")

    return _ax
info()

Display comprehensive information about the SpectralFrame.

This method prints a summary of the frame's properties including: - Number of channels - Sampling rate - FFT size - Frequency range - Number of frequency bins - Frequency resolution (ΔF) - Channel labels

This is a convenience method to view all key properties at once, similar to pandas DataFrame.info().

Examples

spectrum = cf.fft() spectrum.info() SpectralFrame Information: Channels: 2 Sampling rate: 44100 Hz FFT size: 2048 Frequency range: 0.0 - 22050.0 Hz Frequency bins: 1025 Frequency resolution (ΔF): 21.5 Hz Channel labels: ['ch0', 'ch1'] Operations Applied: 1

Source code in wandas/frames/spectral.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
def info(self) -> None:
    """Display comprehensive information about the SpectralFrame.

    This method prints a summary of the frame's properties including:
    - Number of channels
    - Sampling rate
    - FFT size
    - Frequency range
    - Number of frequency bins
    - Frequency resolution (ΔF)
    - Channel labels

    This is a convenience method to view all key properties at once,
    similar to pandas DataFrame.info().

    Examples
    --------
    >>> spectrum = cf.fft()
    >>> spectrum.info()
    SpectralFrame Information:
      Channels: 2
      Sampling rate: 44100 Hz
      FFT size: 2048
      Frequency range: 0.0 - 22050.0 Hz
      Frequency bins: 1025
      Frequency resolution (ΔF): 21.5 Hz
      Channel labels: ['ch0', 'ch1']
      Operations Applied: 1
    """
    # Calculate frequency resolution (ΔF)
    delta_f = self.sampling_rate / self.n_fft

    print("SpectralFrame Information:")
    print(f"  Channels: {self.n_channels}")
    print(f"  Sampling rate: {self.sampling_rate} Hz")
    print(f"  FFT size: {self.n_fft}")
    print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
    print(f"  Frequency bins: {len(self.freqs)}")
    print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
    print(f"  Channel labels: {self.labels}")
    self._print_operation_history()

spectrogram

Attributes
logger = logging.getLogger(__name__) module-attribute
S = TypeVar('S', bound='BaseFrame[Any]') module-attribute
Classes
SpectrogramFrame

Bases: BaseFrame[NDArrayComplex]

Class for handling time-frequency domain data (spectrograms).

This class represents spectrogram data obtained through Short-Time Fourier Transform (STFT) or similar time-frequency analysis methods. It provides methods for visualization, manipulation, and conversion back to time domain.

Parameters

data : DaArray The spectrogram data. Must be a dask array with shape: - (channels, frequency_bins, time_frames) for multi-channel data - (frequency_bins, time_frames) for single-channel data, which will be reshaped to (1, frequency_bins, time_frames) sampling_rate : float The sampling rate of the original time-domain signal in Hz. n_fft : int The FFT size used to generate this spectrogram. hop_length : int Number of samples between successive frames. win_length : int, optional The window length in samples. If None, defaults to n_fft. window : str, default="hann" The window function to use (e.g., "hann", "hamming", "blackman"). label : str, optional A label for the frame. metadata : dict, optional Additional metadata for the frame. operation_history : list[dict], optional History of operations performed on this frame. channel_metadata : list[ChannelMetadata], optional Metadata for each channel in the frame. previous : BaseFrame, optional The frame that this frame was derived from.

Attributes

magnitude : NDArrayReal The magnitude spectrogram. phase : NDArrayReal The phase spectrogram in radians. power : NDArrayReal The power spectrogram. dB : NDArrayReal The spectrogram in decibels relative to channel reference values. dBA : NDArrayReal The A-weighted spectrogram in decibels. n_frames : int Number of time frames. n_freq_bins : int Number of frequency bins. freqs : NDArrayReal The frequency axis values in Hz. times : NDArrayReal The time axis values in seconds.

Examples

Create a spectrogram from a time-domain signal:

signal = ChannelFrame.from_wav("audio.wav") spectrogram = signal.stft(n_fft=2048, hop_length=512)

Extract a specific time frame:

frame_at_1s = spectrogram.get_frame_at(int(1.0 * sampling_rate / hop_length))

Convert back to time domain:

reconstructed = spectrogram.to_channel_frame()

Plot the spectrogram:

spectrogram.plot()

Source code in wandas/frames/spectrogram.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
class SpectrogramFrame(BaseFrame[NDArrayComplex]):
    """
    Class for handling time-frequency domain data (spectrograms).

    This class represents spectrogram data obtained through
    Short-Time Fourier Transform (STFT)
    or similar time-frequency analysis methods. It provides methods for visualization,
    manipulation, and conversion back to time domain.

    Parameters
    ----------
    data : DaArray
        The spectrogram data. Must be a dask array with shape:
        - (channels, frequency_bins, time_frames) for multi-channel data
        - (frequency_bins, time_frames) for single-channel data, which will be
          reshaped to (1, frequency_bins, time_frames)
    sampling_rate : float
        The sampling rate of the original time-domain signal in Hz.
    n_fft : int
        The FFT size used to generate this spectrogram.
    hop_length : int
        Number of samples between successive frames.
    win_length : int, optional
        The window length in samples. If None, defaults to n_fft.
    window : str, default="hann"
        The window function to use (e.g., "hann", "hamming", "blackman").
    label : str, optional
        A label for the frame.
    metadata : dict, optional
        Additional metadata for the frame.
    operation_history : list[dict], optional
        History of operations performed on this frame.
    channel_metadata : list[ChannelMetadata], optional
        Metadata for each channel in the frame.
    previous : BaseFrame, optional
        The frame that this frame was derived from.

    Attributes
    ----------
    magnitude : NDArrayReal
        The magnitude spectrogram.
    phase : NDArrayReal
        The phase spectrogram in radians.
    power : NDArrayReal
        The power spectrogram.
    dB : NDArrayReal
        The spectrogram in decibels relative to channel reference values.
    dBA : NDArrayReal
        The A-weighted spectrogram in decibels.
    n_frames : int
        Number of time frames.
    n_freq_bins : int
        Number of frequency bins.
    freqs : NDArrayReal
        The frequency axis values in Hz.
    times : NDArrayReal
        The time axis values in seconds.

    Examples
    --------
    Create a spectrogram from a time-domain signal:
    >>> signal = ChannelFrame.from_wav("audio.wav")
    >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)

    Extract a specific time frame:
    >>> frame_at_1s = spectrogram.get_frame_at(int(1.0 * sampling_rate / hop_length))

    Convert back to time domain:
    >>> reconstructed = spectrogram.to_channel_frame()

    Plot the spectrogram:
    >>> spectrogram.plot()
    """

    n_fft: int
    hop_length: int
    win_length: int
    window: str

    def __init__(
        self,
        data: DaArray,
        sampling_rate: float,
        n_fft: int,
        hop_length: int,
        win_length: int | None = None,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> None:
        if data.ndim == 2:
            data = da.expand_dims(data, axis=0)  # type: ignore [unused-ignore]
        elif data.ndim != 3:
            raise ValueError(
                f"データは2次元または3次元である必要があります。形状: {data.shape}"
            )
        if not data.shape[-2] == n_fft // 2 + 1:
            raise ValueError(
                f"データの形状が無効です。周波数ビン数は {n_fft // 2 + 1} である必要があります。"  # noqa: E501
            )

        self.n_fft = n_fft
        self.hop_length = hop_length
        self.win_length = win_length if win_length is not None else n_fft
        self.window = window
        super().__init__(
            data=data,
            sampling_rate=sampling_rate,
            label=label,
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )

    @property
    def magnitude(self) -> NDArrayReal:
        """
        Get the magnitude spectrogram.

        Returns
        -------
        NDArrayReal
            The absolute values of the complex spectrogram.
        """
        return np.abs(self.data)

    @property
    def phase(self) -> NDArrayReal:
        """
        Get the phase spectrogram.

        Returns
        -------
        NDArrayReal
            The phase angles of the complex spectrogram in radians.
        """
        return np.angle(self.data)

    @property
    def power(self) -> NDArrayReal:
        """
        Get the power spectrogram.

        Returns
        -------
        NDArrayReal
            The squared magnitude of the complex spectrogram.
        """
        return np.abs(self.data) ** 2

    @property
    def dB(self) -> NDArrayReal:  # noqa: N802
        """
        Get the spectrogram in decibels relative to each channel's reference value.

        The reference value for each channel is specified in its metadata.
        A minimum value of -120 dB is enforced to avoid numerical issues.

        Returns
        -------
        NDArrayReal
            The spectrogram in decibels.
        """
        # dB規定値を_channel_metadataから収集
        ref = np.array([ch.ref for ch in self._channel_metadata])
        # dB変換
        # 0除算を避けるために、最大値と1e-12のいずれかを使用
        level: NDArrayReal = 20 * np.log10(
            np.maximum(self.magnitude / ref[..., np.newaxis, np.newaxis], 1e-12)
        )
        return level

    @property
    def dBA(self) -> NDArrayReal:  # noqa: N802
        """
        Get the A-weighted spectrogram in decibels.

        A-weighting applies a frequency-dependent weighting filter that approximates
        the human ear's response. This is particularly useful for analyzing noise
        and acoustic measurements.

        Returns
        -------
        NDArrayReal
            The A-weighted spectrogram in decibels.
        """
        weighted: NDArrayReal = librosa.A_weighting(frequencies=self.freqs, min_db=None)
        return self.dB + weighted[:, np.newaxis]  # 周波数軸に沿ってブロードキャスト

    @property
    def _n_channels(self) -> int:
        """
        Get the number of channels in the data.

        Returns
        -------
        int
            The number of channels.
        """
        return int(self._data.shape[-3])

    @property
    def n_frames(self) -> int:
        """
        Get the number of time frames.

        Returns
        -------
        int
            The number of time frames in the spectrogram.
        """
        return self.shape[-1]

    @property
    def n_freq_bins(self) -> int:
        """
        Get the number of frequency bins.

        Returns
        -------
        int
            The number of frequency bins (n_fft // 2 + 1).
        """
        return self.shape[-2]

    @property
    def freqs(self) -> NDArrayReal:
        """
        Get the frequency axis values in Hz.

        Returns
        -------
        NDArrayReal
            Array of frequency values corresponding to each frequency bin.
        """
        return np.fft.rfftfreq(self.n_fft, 1.0 / self.sampling_rate)

    @property
    def times(self) -> NDArrayReal:
        """
        Get the time axis values in seconds.

        Returns
        -------
        NDArrayReal
            Array of time values corresponding to each time frame.
        """
        return np.arange(self.n_frames) * self.hop_length / self.sampling_rate

    def _apply_operation_impl(self: S, operation_name: str, **params: Any) -> S:
        """
        Implementation of operation application for spectrogram data.

        This internal method handles the application of various operations to
        spectrogram data, maintaining lazy evaluation through dask.

        Parameters
        ----------
        operation_name : str
            Name of the operation to apply.
        **params : Any
            Parameters for the operation.

        Returns
        -------
        S
            A new instance with the operation applied.
        """
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")
        from wandas.processing import create_operation

        operation = create_operation(operation_name, self.sampling_rate, **params)
        processed_data = operation.process(self._data)

        operation_metadata = {"operation": operation_name, "params": params}
        new_history = self.operation_history.copy()
        new_history.append(operation_metadata)
        new_metadata = {**self.metadata}
        new_metadata[operation_name] = params

        logger.debug(
            f"Created new SpectrogramFrame with operation {operation_name} added to graph"  # noqa: E501
        )
        return self._create_new_instance(
            data=processed_data,
            metadata=new_metadata,
            operation_history=new_history,
        )

    def _binary_op(
        self,
        other: Union[
            "SpectrogramFrame",
            int,
            float,
            complex,
            NDArrayComplex,
            NDArrayReal,
            "DaArray",
        ],
        op: Callable[["DaArray", Any], "DaArray"],
        symbol: str,
    ) -> "SpectrogramFrame":
        """
        Common implementation for binary operations.

        This method handles binary operations between
        SpectrogramFrames and various types
        of operands, maintaining lazy evaluation through dask arrays.

        Parameters
        ----------
        other : Union[SpectrogramFrame, int, float, complex,
            NDArrayComplex, NDArrayReal, DaArray]
            The right operand of the operation.
        op : callable
            Function to execute the operation (e.g., lambda a, b: a + b)
        symbol : str
            String representation of the operation (e.g., '+')

        Returns
        -------
        SpectrogramFrame
            A new SpectrogramFrame containing the result of the operation.

        Raises
        ------
        ValueError
            If attempting to operate with a SpectrogramFrame
            with a different sampling rate.
        """
        logger.debug(f"Setting up {symbol} operation (lazy)")

        metadata = {}
        if self.metadata is not None:
            metadata = self.metadata.copy()

        operation_history = []
        if self.operation_history is not None:
            operation_history = self.operation_history.copy()

        if isinstance(other, SpectrogramFrame):
            if self.sampling_rate != other.sampling_rate:
                raise ValueError(
                    "サンプリングレートが一致していません。演算できません。"
                )

            result_data = op(self._data, other._data)

            merged_channel_metadata = []
            for self_ch, other_ch in zip(
                self._channel_metadata, other._channel_metadata
            ):
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch['label']} {symbol} {other_ch['label']})"
                merged_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other.label})

            return SpectrogramFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                n_fft=self.n_fft,
                hop_length=self.hop_length,
                win_length=self.win_length,
                window=self.window,
                label=f"({self.label} {symbol} {other.label})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=merged_channel_metadata,
                previous=self,
            )
        else:
            result_data = op(self._data, other)

            if isinstance(other, int | float):
                other_str = str(other)
            elif isinstance(other, complex):
                other_str = f"complex({other.real}, {other.imag})"
            elif isinstance(other, np.ndarray):
                other_str = f"ndarray{other.shape}"
            elif hasattr(other, "shape"):
                other_str = f"dask.array{other.shape}"
            else:
                other_str = str(type(other).__name__)

            updated_channel_metadata: list[ChannelMetadata] = []
            for self_ch in self._channel_metadata:
                ch = self_ch.model_copy(deep=True)
                ch["label"] = f"({self_ch.label} {symbol} {other_str})"
                updated_channel_metadata.append(ch)

            operation_history.append({"operation": symbol, "with": other_str})

            return SpectrogramFrame(
                data=result_data,
                sampling_rate=self.sampling_rate,
                n_fft=self.n_fft,
                hop_length=self.hop_length,
                win_length=self.win_length,
                window=self.window,
                label=f"({self.label} {symbol} {other_str})",
                metadata=metadata,
                operation_history=operation_history,
                channel_metadata=updated_channel_metadata,
            )

    def plot(
        self,
        plot_type: str = "spectrogram",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        cmap: str = "jet",
        vmin: float | None = None,
        vmax: float | None = None,
        fmin: float = 0,
        fmax: float | None = None,
        xlim: tuple[float, float] | None = None,
        ylim: tuple[float, float] | None = None,
        Aw: bool = False,  # noqa: N803
        **kwargs: Any,
    ) -> Union["Axes", Iterator["Axes"]]:
        """
        Plot the spectrogram using various visualization strategies.

        Parameters
        ----------
        plot_type : str, default="spectrogram"
            Type of plot to create.
        ax : matplotlib.axes.Axes, optional
            Axes to plot on. If None, creates new axes.
        title : str, optional
            Title for the plot. If None, uses the frame label.
        cmap : str, default="jet"
            Colormap name for the spectrogram visualization.
        vmin : float, optional
            Minimum value for colormap scaling (dB). Auto-calculated if None.
        vmax : float, optional
            Maximum value for colormap scaling (dB). Auto-calculated if None.
        fmin : float, default=0
            Minimum frequency to display (Hz).
        fmax : float, optional
            Maximum frequency to display (Hz). If None, uses Nyquist frequency.
        xlim : tuple[float, float], optional
            Time axis limits as (start_time, end_time) in seconds.
        ylim : tuple[float, float], optional
            Frequency axis limits as (min_freq, max_freq) in Hz.
        Aw : bool, default=False
            Whether to apply A-weighting to the spectrogram.
        **kwargs : dict
            Additional keyword arguments passed to librosa.display.specshow().

        Returns
        -------
        Union[Axes, Iterator[Axes]]
            The matplotlib axes containing the plot, or an iterator of axes
            for multi-plot outputs.

        Examples
        --------
        >>> stft = cf.stft()
        >>> # Basic spectrogram
        >>> stft.plot()
        >>> # Custom color scale and frequency range
        >>> stft.plot(vmin=-80, vmax=-20, fmin=100, fmax=5000)
        >>> # A-weighted spectrogram
        >>> stft.plot(Aw=True, cmap="viridis")
        """
        from wandas.visualization.plotting import create_operation

        logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

        # プロット戦略を取得
        plot_strategy: PlotStrategy[SpectrogramFrame] = create_operation(plot_type)

        # Build kwargs for plot strategy
        plot_kwargs = {
            "title": title,
            "cmap": cmap,
            "vmin": vmin,
            "vmax": vmax,
            "fmin": fmin,
            "fmax": fmax,
            "Aw": Aw,
            **kwargs,
        }
        if xlim is not None:
            plot_kwargs["xlim"] = xlim
        if ylim is not None:
            plot_kwargs["ylim"] = ylim

        # プロット実行
        _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

        logger.debug("Plot rendering complete")

        return _ax

    def plot_Aw(  # noqa: N802
        self,
        plot_type: str = "spectrogram",
        ax: Optional["Axes"] = None,
        **kwargs: Any,
    ) -> Union["Axes", Iterator["Axes"]]:
        """
        Plot the A-weighted spectrogram.

        A convenience method that calls plot() with Aw=True, applying A-weighting
        to the spectrogram before plotting.

        Parameters
        ----------
        plot_type : str, default="spectrogram"
            Type of plot to create.
        ax : matplotlib.axes.Axes, optional
            Axes to plot on. If None, creates new axes.
        **kwargs : dict
            Additional keyword arguments passed to plot().
            Accepts all parameters from plot() except Aw (which is set to True).

        Returns
        -------
        Union[Axes, Iterator[Axes]]
            The matplotlib axes containing the plot.

        Examples
        --------
        >>> stft = cf.stft()
        >>> # A-weighted spectrogram with custom settings
        >>> stft.plot_Aw(vmin=-60, vmax=-10, cmap="magma")
        """
        return self.plot(plot_type=plot_type, ax=ax, Aw=True, **kwargs)

    def abs(self) -> "SpectrogramFrame":
        """
        Compute the absolute value (magnitude) of the complex spectrogram.

        This method calculates the magnitude of each complex value in the
        spectrogram, converting the complex-valued data to real-valued magnitude data.
        The result is stored in a new SpectrogramFrame with complex dtype to maintain
        compatibility with other spectrogram operations.

        Returns
        -------
        SpectrogramFrame
            A new SpectrogramFrame containing the magnitude values as complex numbers
            (with zero imaginary parts).

        Examples
        --------
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
        >>> magnitude_spectrogram = spectrogram.abs()
        >>> # The magnitude can be accessed via the magnitude property or data
        >>> print(magnitude_spectrogram.magnitude.shape)
        """
        logger.debug("Computing absolute value (magnitude) of spectrogram")

        # Compute the absolute value using dask for lazy evaluation
        magnitude_data = da.absolute(self._data)

        # Update operation history
        operation_metadata = {"operation": "abs", "params": {}}
        new_history = self.operation_history.copy()
        new_history.append(operation_metadata)
        new_metadata = {**self.metadata}
        new_metadata["abs"] = {}

        logger.debug("Created new SpectrogramFrame with abs operation added to graph")

        return SpectrogramFrame(
            data=magnitude_data,
            sampling_rate=self.sampling_rate,
            n_fft=self.n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=self.window,
            label=f"abs({self.label})",
            metadata=new_metadata,
            operation_history=new_history,
            channel_metadata=self._channel_metadata,
            previous=self,
        )

    def get_frame_at(self, time_idx: int) -> "SpectralFrame":
        """
        Extract spectral data at a specific time frame.

        Parameters
        ----------
        time_idx : int
            Index of the time frame to extract.

        Returns
        -------
        SpectralFrame
            A new SpectralFrame containing the spectral data at the specified time.

        Raises
        ------
        IndexError
            If time_idx is out of range.
        """
        from wandas.frames.spectral import SpectralFrame

        if time_idx < 0 or time_idx >= self.n_frames:
            raise IndexError(
                f"時間インデックス {time_idx} が範囲外です。有効範囲: 0-{self.n_frames - 1}"  # noqa: E501
            )

        frame_data = self._data[..., time_idx]

        return SpectralFrame(
            data=frame_data,
            sampling_rate=self.sampling_rate,
            n_fft=self.n_fft,
            window=self.window,
            label=f"{self.label} (Frame {time_idx}, Time {self.times[time_idx]:.3f}s)",
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
        )

    def to_channel_frame(self) -> "ChannelFrame":
        """
        Convert the spectrogram back to time domain using inverse STFT.

        This method performs an inverse Short-Time Fourier Transform (ISTFT) to
        reconstruct the time-domain signal from the spectrogram.

        Returns
        -------
        ChannelFrame
            A new ChannelFrame containing the reconstructed time-domain signal.

        See Also
        --------
        istft : Alias for this method with more intuitive naming.
        """
        from wandas.frames.channel import ChannelFrame
        from wandas.processing import ISTFT, create_operation

        params = {
            "n_fft": self.n_fft,
            "hop_length": self.hop_length,
            "win_length": self.win_length,
            "window": self.window,
        }
        operation_name = "istft"
        logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

        # 操作インスタンスを作成
        operation = create_operation(operation_name, self.sampling_rate, **params)
        operation = cast("ISTFT", operation)
        # データに処理を適用
        time_series = operation.process(self._data)

        logger.debug(
            f"Created new ChannelFrame with operation {operation_name} added to graph"
        )

        # 新しいインスタンスを作成
        return ChannelFrame(
            data=time_series,
            sampling_rate=self.sampling_rate,
            label=f"istft({self.label})",
            metadata=self.metadata,
            operation_history=self.operation_history,
            channel_metadata=self._channel_metadata,
        )

    def istft(self) -> "ChannelFrame":
        """
        Convert the spectrogram back to time domain using inverse STFT.

        This is an alias for `to_channel_frame()` with a more intuitive name.
        It performs an inverse Short-Time Fourier Transform (ISTFT) to
        reconstruct the time-domain signal from the spectrogram.

        Returns
        -------
        ChannelFrame
            A new ChannelFrame containing the reconstructed time-domain signal.

        See Also
        --------
        to_channel_frame : The underlying implementation.

        Examples
        --------
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
        >>> reconstructed = spectrogram.istft()
        """
        return self.to_channel_frame()

    def _get_additional_init_kwargs(self) -> dict[str, Any]:
        """
        Get additional initialization arguments for SpectrogramFrame.

        This internal method provides the additional initialization arguments
        required by SpectrogramFrame beyond those required by BaseFrame.

        Returns
        -------
        dict[str, Any]
            Additional initialization arguments.
        """
        return {
            "n_fft": self.n_fft,
            "hop_length": self.hop_length,
            "win_length": self.win_length,
            "window": self.window,
        }

    def _get_dataframe_columns(self) -> list[str]:
        """Get channel labels as DataFrame columns."""
        return [ch.label for ch in self._channel_metadata]

    def _get_dataframe_index(self) -> "pd.Index[Any]":
        """DataFrame index is not supported for SpectrogramFrame."""
        raise NotImplementedError(
            "DataFrame index is not supported for SpectrogramFrame."
        )

    def to_dataframe(self) -> "pd.DataFrame":
        """DataFrame conversion is not supported for SpectrogramFrame.

        SpectrogramFrame contains 3D data (channels, frequency_bins, time_frames)
        which cannot be directly converted to a 2D DataFrame. Consider using
        get_frame_at() to extract a specific time frame as a SpectralFrame,
        then convert that to a DataFrame.

        Raises
        ------
        NotImplementedError
            Always raised as DataFrame conversion is not supported.
        """
        raise NotImplementedError(
            "DataFrame conversion is not supported for SpectrogramFrame. "
            "Use get_frame_at() to extract a specific time frame as SpectralFrame, "
            "then convert that to a DataFrame."
        )

    def info(self) -> None:
        """Display comprehensive information about the SpectrogramFrame.

        This method prints a summary of the frame's properties including:
        - Number of channels
        - Sampling rate
        - FFT size
        - Hop length
        - Window length
        - Window function
        - Frequency range
        - Number of frequency bins
        - Frequency resolution (ΔF)
        - Number of time frames
        - Time resolution (ΔT)
        - Total duration
        - Channel labels
        - Number of operations applied

        This is a convenience method to view all key properties at once,
        similar to pandas DataFrame.info().

        Examples
        --------
        >>> signal = ChannelFrame.from_wav("audio.wav")
        >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
        >>> spectrogram.info()
        SpectrogramFrame Information:
          Channels: 2
          Sampling rate: 44100 Hz
          FFT size: 2048
          Hop length: 512 samples
          Window length: 2048 samples
          Window: hann
          Frequency range: 0.0 - 22050.0 Hz
          Frequency bins: 1025
          Frequency resolution (ΔF): 21.5 Hz
          Time frames: 100
          Time resolution (ΔT): 11.6 ms
          Total duration: 1.16 s
          Channel labels: ['ch0', 'ch1']
          Operations Applied: 1
        """
        # Calculate frequency resolution (ΔF) and time resolution (ΔT)
        delta_f = self.sampling_rate / self.n_fft
        delta_t_ms = (self.hop_length / self.sampling_rate) * 1000
        total_duration = (self.n_frames * self.hop_length) / self.sampling_rate

        print("SpectrogramFrame Information:")
        print(f"  Channels: {self.n_channels}")
        print(f"  Sampling rate: {self.sampling_rate} Hz")
        print(f"  FFT size: {self.n_fft}")
        print(f"  Hop length: {self.hop_length} samples")
        print(f"  Window length: {self.win_length} samples")
        print(f"  Window: {self.window}")
        print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
        print(f"  Frequency bins: {self.n_freq_bins}")
        print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
        print(f"  Time frames: {self.n_frames}")
        print(f"  Time resolution (ΔT): {delta_t_ms:.1f} ms")
        print(f"  Total duration: {total_duration:.2f} s")
        print(f"  Channel labels: {self.labels}")
        self._print_operation_history()

    @classmethod
    def from_numpy(
        cls,
        data: NDArrayComplex,
        sampling_rate: float,
        n_fft: int,
        hop_length: int,
        win_length: int | None = None,
        window: str = "hann",
        label: str | None = None,
        metadata: dict[str, Any] | None = None,
        operation_history: list[dict[str, Any]] | None = None,
        channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
        previous: Optional["BaseFrame[Any]"] = None,
    ) -> "SpectrogramFrame":
        """Create a SpectrogramFrame from a NumPy array.

        Args:
            data: NumPy array containing spectrogram data.
                Shape should be (n_channels, n_freq_bins, n_time_frames) or
                (n_freq_bins, n_time_frames) for single channel.
            sampling_rate: The sampling rate in Hz.
            n_fft: The FFT size used to generate this spectrogram.
            hop_length: Number of samples between successive frames.
            win_length: The window length in samples. If None, defaults to n_fft.
            window: The window function used (e.g., "hann", "hamming").
            label: A label for the frame.
            metadata: Optional metadata dictionary.
            operation_history: History of operations applied to the frame.
            channel_metadata: Metadata for each channel.
            previous: Reference to the previous frame in the processing chain.

        Returns:
            A new SpectrogramFrame containing the NumPy data.
        """

        # Convert NumPy array to dask array
        dask_data = da.from_array(data)
        sf = cls(
            data=dask_data,
            sampling_rate=sampling_rate,
            n_fft=n_fft,
            hop_length=hop_length,
            win_length=win_length,
            window=window,
            label=label or "numpy_spectrogram",
            metadata=metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata,
            previous=previous,
        )
        return sf
Attributes
n_fft = n_fft instance-attribute
hop_length = hop_length instance-attribute
win_length = win_length if win_length is not None else n_fft instance-attribute
window = window instance-attribute
magnitude property

Get the magnitude spectrogram.

Returns

NDArrayReal The absolute values of the complex spectrogram.

phase property

Get the phase spectrogram.

Returns

NDArrayReal The phase angles of the complex spectrogram in radians.

power property

Get the power spectrogram.

Returns

NDArrayReal The squared magnitude of the complex spectrogram.

dB property

Get the spectrogram in decibels relative to each channel's reference value.

The reference value for each channel is specified in its metadata. A minimum value of -120 dB is enforced to avoid numerical issues.

Returns

NDArrayReal The spectrogram in decibels.

dBA property

Get the A-weighted spectrogram in decibels.

A-weighting applies a frequency-dependent weighting filter that approximates the human ear's response. This is particularly useful for analyzing noise and acoustic measurements.

Returns

NDArrayReal The A-weighted spectrogram in decibels.

n_frames property

Get the number of time frames.

Returns

int The number of time frames in the spectrogram.

n_freq_bins property

Get the number of frequency bins.

Returns

int The number of frequency bins (n_fft // 2 + 1).

freqs property

Get the frequency axis values in Hz.

Returns

NDArrayReal Array of frequency values corresponding to each frequency bin.

times property

Get the time axis values in seconds.

Returns

NDArrayReal Array of time values corresponding to each time frame.

Functions
__init__(data, sampling_rate, n_fft, hop_length, win_length=None, window='hann', label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None)
Source code in wandas/frames/spectrogram.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def __init__(
    self,
    data: DaArray,
    sampling_rate: float,
    n_fft: int,
    hop_length: int,
    win_length: int | None = None,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> None:
    if data.ndim == 2:
        data = da.expand_dims(data, axis=0)  # type: ignore [unused-ignore]
    elif data.ndim != 3:
        raise ValueError(
            f"データは2次元または3次元である必要があります。形状: {data.shape}"
        )
    if not data.shape[-2] == n_fft // 2 + 1:
        raise ValueError(
            f"データの形状が無効です。周波数ビン数は {n_fft // 2 + 1} である必要があります。"  # noqa: E501
        )

    self.n_fft = n_fft
    self.hop_length = hop_length
    self.win_length = win_length if win_length is not None else n_fft
    self.window = window
    super().__init__(
        data=data,
        sampling_rate=sampling_rate,
        label=label,
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
plot(plot_type='spectrogram', ax=None, title=None, cmap='jet', vmin=None, vmax=None, fmin=0, fmax=None, xlim=None, ylim=None, Aw=False, **kwargs)

Plot the spectrogram using various visualization strategies.

Parameters

plot_type : str, default="spectrogram" Type of plot to create. ax : matplotlib.axes.Axes, optional Axes to plot on. If None, creates new axes. title : str, optional Title for the plot. If None, uses the frame label. cmap : str, default="jet" Colormap name for the spectrogram visualization. vmin : float, optional Minimum value for colormap scaling (dB). Auto-calculated if None. vmax : float, optional Maximum value for colormap scaling (dB). Auto-calculated if None. fmin : float, default=0 Minimum frequency to display (Hz). fmax : float, optional Maximum frequency to display (Hz). If None, uses Nyquist frequency. xlim : tuple[float, float], optional Time axis limits as (start_time, end_time) in seconds. ylim : tuple[float, float], optional Frequency axis limits as (min_freq, max_freq) in Hz. Aw : bool, default=False Whether to apply A-weighting to the spectrogram. **kwargs : dict Additional keyword arguments passed to librosa.display.specshow().

Returns

Union[Axes, Iterator[Axes]] The matplotlib axes containing the plot, or an iterator of axes for multi-plot outputs.

Examples

stft = cf.stft()

Basic spectrogram

stft.plot()

Custom color scale and frequency range

stft.plot(vmin=-80, vmax=-20, fmin=100, fmax=5000)

A-weighted spectrogram

stft.plot(Aw=True, cmap="viridis")

Source code in wandas/frames/spectrogram.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
def plot(
    self,
    plot_type: str = "spectrogram",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    cmap: str = "jet",
    vmin: float | None = None,
    vmax: float | None = None,
    fmin: float = 0,
    fmax: float | None = None,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    Aw: bool = False,  # noqa: N803
    **kwargs: Any,
) -> Union["Axes", Iterator["Axes"]]:
    """
    Plot the spectrogram using various visualization strategies.

    Parameters
    ----------
    plot_type : str, default="spectrogram"
        Type of plot to create.
    ax : matplotlib.axes.Axes, optional
        Axes to plot on. If None, creates new axes.
    title : str, optional
        Title for the plot. If None, uses the frame label.
    cmap : str, default="jet"
        Colormap name for the spectrogram visualization.
    vmin : float, optional
        Minimum value for colormap scaling (dB). Auto-calculated if None.
    vmax : float, optional
        Maximum value for colormap scaling (dB). Auto-calculated if None.
    fmin : float, default=0
        Minimum frequency to display (Hz).
    fmax : float, optional
        Maximum frequency to display (Hz). If None, uses Nyquist frequency.
    xlim : tuple[float, float], optional
        Time axis limits as (start_time, end_time) in seconds.
    ylim : tuple[float, float], optional
        Frequency axis limits as (min_freq, max_freq) in Hz.
    Aw : bool, default=False
        Whether to apply A-weighting to the spectrogram.
    **kwargs : dict
        Additional keyword arguments passed to librosa.display.specshow().

    Returns
    -------
    Union[Axes, Iterator[Axes]]
        The matplotlib axes containing the plot, or an iterator of axes
        for multi-plot outputs.

    Examples
    --------
    >>> stft = cf.stft()
    >>> # Basic spectrogram
    >>> stft.plot()
    >>> # Custom color scale and frequency range
    >>> stft.plot(vmin=-80, vmax=-20, fmin=100, fmax=5000)
    >>> # A-weighted spectrogram
    >>> stft.plot(Aw=True, cmap="viridis")
    """
    from wandas.visualization.plotting import create_operation

    logger.debug(f"Plotting audio with plot_type={plot_type} (will compute now)")

    # プロット戦略を取得
    plot_strategy: PlotStrategy[SpectrogramFrame] = create_operation(plot_type)

    # Build kwargs for plot strategy
    plot_kwargs = {
        "title": title,
        "cmap": cmap,
        "vmin": vmin,
        "vmax": vmax,
        "fmin": fmin,
        "fmax": fmax,
        "Aw": Aw,
        **kwargs,
    }
    if xlim is not None:
        plot_kwargs["xlim"] = xlim
    if ylim is not None:
        plot_kwargs["ylim"] = ylim

    # プロット実行
    _ax = plot_strategy.plot(self, ax=ax, **plot_kwargs)

    logger.debug("Plot rendering complete")

    return _ax
plot_Aw(plot_type='spectrogram', ax=None, **kwargs)

Plot the A-weighted spectrogram.

A convenience method that calls plot() with Aw=True, applying A-weighting to the spectrogram before plotting.

Parameters

plot_type : str, default="spectrogram" Type of plot to create. ax : matplotlib.axes.Axes, optional Axes to plot on. If None, creates new axes. **kwargs : dict Additional keyword arguments passed to plot(). Accepts all parameters from plot() except Aw (which is set to True).

Returns

Union[Axes, Iterator[Axes]] The matplotlib axes containing the plot.

Examples

stft = cf.stft()

A-weighted spectrogram with custom settings

stft.plot_Aw(vmin=-60, vmax=-10, cmap="magma")

Source code in wandas/frames/spectrogram.py
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
def plot_Aw(  # noqa: N802
    self,
    plot_type: str = "spectrogram",
    ax: Optional["Axes"] = None,
    **kwargs: Any,
) -> Union["Axes", Iterator["Axes"]]:
    """
    Plot the A-weighted spectrogram.

    A convenience method that calls plot() with Aw=True, applying A-weighting
    to the spectrogram before plotting.

    Parameters
    ----------
    plot_type : str, default="spectrogram"
        Type of plot to create.
    ax : matplotlib.axes.Axes, optional
        Axes to plot on. If None, creates new axes.
    **kwargs : dict
        Additional keyword arguments passed to plot().
        Accepts all parameters from plot() except Aw (which is set to True).

    Returns
    -------
    Union[Axes, Iterator[Axes]]
        The matplotlib axes containing the plot.

    Examples
    --------
    >>> stft = cf.stft()
    >>> # A-weighted spectrogram with custom settings
    >>> stft.plot_Aw(vmin=-60, vmax=-10, cmap="magma")
    """
    return self.plot(plot_type=plot_type, ax=ax, Aw=True, **kwargs)
abs()

Compute the absolute value (magnitude) of the complex spectrogram.

This method calculates the magnitude of each complex value in the spectrogram, converting the complex-valued data to real-valued magnitude data. The result is stored in a new SpectrogramFrame with complex dtype to maintain compatibility with other spectrogram operations.

Returns

SpectrogramFrame A new SpectrogramFrame containing the magnitude values as complex numbers (with zero imaginary parts).

Examples

signal = ChannelFrame.from_wav("audio.wav") spectrogram = signal.stft(n_fft=2048, hop_length=512) magnitude_spectrogram = spectrogram.abs()

The magnitude can be accessed via the magnitude property or data

print(magnitude_spectrogram.magnitude.shape)

Source code in wandas/frames/spectrogram.py
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
def abs(self) -> "SpectrogramFrame":
    """
    Compute the absolute value (magnitude) of the complex spectrogram.

    This method calculates the magnitude of each complex value in the
    spectrogram, converting the complex-valued data to real-valued magnitude data.
    The result is stored in a new SpectrogramFrame with complex dtype to maintain
    compatibility with other spectrogram operations.

    Returns
    -------
    SpectrogramFrame
        A new SpectrogramFrame containing the magnitude values as complex numbers
        (with zero imaginary parts).

    Examples
    --------
    >>> signal = ChannelFrame.from_wav("audio.wav")
    >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
    >>> magnitude_spectrogram = spectrogram.abs()
    >>> # The magnitude can be accessed via the magnitude property or data
    >>> print(magnitude_spectrogram.magnitude.shape)
    """
    logger.debug("Computing absolute value (magnitude) of spectrogram")

    # Compute the absolute value using dask for lazy evaluation
    magnitude_data = da.absolute(self._data)

    # Update operation history
    operation_metadata = {"operation": "abs", "params": {}}
    new_history = self.operation_history.copy()
    new_history.append(operation_metadata)
    new_metadata = {**self.metadata}
    new_metadata["abs"] = {}

    logger.debug("Created new SpectrogramFrame with abs operation added to graph")

    return SpectrogramFrame(
        data=magnitude_data,
        sampling_rate=self.sampling_rate,
        n_fft=self.n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=self.window,
        label=f"abs({self.label})",
        metadata=new_metadata,
        operation_history=new_history,
        channel_metadata=self._channel_metadata,
        previous=self,
    )
get_frame_at(time_idx)

Extract spectral data at a specific time frame.

Parameters

time_idx : int Index of the time frame to extract.

Returns

SpectralFrame A new SpectralFrame containing the spectral data at the specified time.

Raises

IndexError If time_idx is out of range.

Source code in wandas/frames/spectrogram.py
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
def get_frame_at(self, time_idx: int) -> "SpectralFrame":
    """
    Extract spectral data at a specific time frame.

    Parameters
    ----------
    time_idx : int
        Index of the time frame to extract.

    Returns
    -------
    SpectralFrame
        A new SpectralFrame containing the spectral data at the specified time.

    Raises
    ------
    IndexError
        If time_idx is out of range.
    """
    from wandas.frames.spectral import SpectralFrame

    if time_idx < 0 or time_idx >= self.n_frames:
        raise IndexError(
            f"時間インデックス {time_idx} が範囲外です。有効範囲: 0-{self.n_frames - 1}"  # noqa: E501
        )

    frame_data = self._data[..., time_idx]

    return SpectralFrame(
        data=frame_data,
        sampling_rate=self.sampling_rate,
        n_fft=self.n_fft,
        window=self.window,
        label=f"{self.label} (Frame {time_idx}, Time {self.times[time_idx]:.3f}s)",
        metadata=self.metadata,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
    )
to_channel_frame()

Convert the spectrogram back to time domain using inverse STFT.

This method performs an inverse Short-Time Fourier Transform (ISTFT) to reconstruct the time-domain signal from the spectrogram.

Returns

ChannelFrame A new ChannelFrame containing the reconstructed time-domain signal.

See Also

istft : Alias for this method with more intuitive naming.

Source code in wandas/frames/spectrogram.py
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
def to_channel_frame(self) -> "ChannelFrame":
    """
    Convert the spectrogram back to time domain using inverse STFT.

    This method performs an inverse Short-Time Fourier Transform (ISTFT) to
    reconstruct the time-domain signal from the spectrogram.

    Returns
    -------
    ChannelFrame
        A new ChannelFrame containing the reconstructed time-domain signal.

    See Also
    --------
    istft : Alias for this method with more intuitive naming.
    """
    from wandas.frames.channel import ChannelFrame
    from wandas.processing import ISTFT, create_operation

    params = {
        "n_fft": self.n_fft,
        "hop_length": self.hop_length,
        "win_length": self.win_length,
        "window": self.window,
    }
    operation_name = "istft"
    logger.debug(f"Applying operation={operation_name} with params={params} (lazy)")

    # 操作インスタンスを作成
    operation = create_operation(operation_name, self.sampling_rate, **params)
    operation = cast("ISTFT", operation)
    # データに処理を適用
    time_series = operation.process(self._data)

    logger.debug(
        f"Created new ChannelFrame with operation {operation_name} added to graph"
    )

    # 新しいインスタンスを作成
    return ChannelFrame(
        data=time_series,
        sampling_rate=self.sampling_rate,
        label=f"istft({self.label})",
        metadata=self.metadata,
        operation_history=self.operation_history,
        channel_metadata=self._channel_metadata,
    )
istft()

Convert the spectrogram back to time domain using inverse STFT.

This is an alias for to_channel_frame() with a more intuitive name. It performs an inverse Short-Time Fourier Transform (ISTFT) to reconstruct the time-domain signal from the spectrogram.

Returns

ChannelFrame A new ChannelFrame containing the reconstructed time-domain signal.

See Also

to_channel_frame : The underlying implementation.

Examples

signal = ChannelFrame.from_wav("audio.wav") spectrogram = signal.stft(n_fft=2048, hop_length=512) reconstructed = spectrogram.istft()

Source code in wandas/frames/spectrogram.py
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
def istft(self) -> "ChannelFrame":
    """
    Convert the spectrogram back to time domain using inverse STFT.

    This is an alias for `to_channel_frame()` with a more intuitive name.
    It performs an inverse Short-Time Fourier Transform (ISTFT) to
    reconstruct the time-domain signal from the spectrogram.

    Returns
    -------
    ChannelFrame
        A new ChannelFrame containing the reconstructed time-domain signal.

    See Also
    --------
    to_channel_frame : The underlying implementation.

    Examples
    --------
    >>> signal = ChannelFrame.from_wav("audio.wav")
    >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
    >>> reconstructed = spectrogram.istft()
    """
    return self.to_channel_frame()
to_dataframe()

DataFrame conversion is not supported for SpectrogramFrame.

SpectrogramFrame contains 3D data (channels, frequency_bins, time_frames) which cannot be directly converted to a 2D DataFrame. Consider using get_frame_at() to extract a specific time frame as a SpectralFrame, then convert that to a DataFrame.

Raises

NotImplementedError Always raised as DataFrame conversion is not supported.

Source code in wandas/frames/spectrogram.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
def to_dataframe(self) -> "pd.DataFrame":
    """DataFrame conversion is not supported for SpectrogramFrame.

    SpectrogramFrame contains 3D data (channels, frequency_bins, time_frames)
    which cannot be directly converted to a 2D DataFrame. Consider using
    get_frame_at() to extract a specific time frame as a SpectralFrame,
    then convert that to a DataFrame.

    Raises
    ------
    NotImplementedError
        Always raised as DataFrame conversion is not supported.
    """
    raise NotImplementedError(
        "DataFrame conversion is not supported for SpectrogramFrame. "
        "Use get_frame_at() to extract a specific time frame as SpectralFrame, "
        "then convert that to a DataFrame."
    )
info()

Display comprehensive information about the SpectrogramFrame.

This method prints a summary of the frame's properties including: - Number of channels - Sampling rate - FFT size - Hop length - Window length - Window function - Frequency range - Number of frequency bins - Frequency resolution (ΔF) - Number of time frames - Time resolution (ΔT) - Total duration - Channel labels - Number of operations applied

This is a convenience method to view all key properties at once, similar to pandas DataFrame.info().

Examples

signal = ChannelFrame.from_wav("audio.wav") spectrogram = signal.stft(n_fft=2048, hop_length=512) spectrogram.info() SpectrogramFrame Information: Channels: 2 Sampling rate: 44100 Hz FFT size: 2048 Hop length: 512 samples Window length: 2048 samples Window: hann Frequency range: 0.0 - 22050.0 Hz Frequency bins: 1025 Frequency resolution (ΔF): 21.5 Hz Time frames: 100 Time resolution (ΔT): 11.6 ms Total duration: 1.16 s Channel labels: ['ch0', 'ch1'] Operations Applied: 1

Source code in wandas/frames/spectrogram.py
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
def info(self) -> None:
    """Display comprehensive information about the SpectrogramFrame.

    This method prints a summary of the frame's properties including:
    - Number of channels
    - Sampling rate
    - FFT size
    - Hop length
    - Window length
    - Window function
    - Frequency range
    - Number of frequency bins
    - Frequency resolution (ΔF)
    - Number of time frames
    - Time resolution (ΔT)
    - Total duration
    - Channel labels
    - Number of operations applied

    This is a convenience method to view all key properties at once,
    similar to pandas DataFrame.info().

    Examples
    --------
    >>> signal = ChannelFrame.from_wav("audio.wav")
    >>> spectrogram = signal.stft(n_fft=2048, hop_length=512)
    >>> spectrogram.info()
    SpectrogramFrame Information:
      Channels: 2
      Sampling rate: 44100 Hz
      FFT size: 2048
      Hop length: 512 samples
      Window length: 2048 samples
      Window: hann
      Frequency range: 0.0 - 22050.0 Hz
      Frequency bins: 1025
      Frequency resolution (ΔF): 21.5 Hz
      Time frames: 100
      Time resolution (ΔT): 11.6 ms
      Total duration: 1.16 s
      Channel labels: ['ch0', 'ch1']
      Operations Applied: 1
    """
    # Calculate frequency resolution (ΔF) and time resolution (ΔT)
    delta_f = self.sampling_rate / self.n_fft
    delta_t_ms = (self.hop_length / self.sampling_rate) * 1000
    total_duration = (self.n_frames * self.hop_length) / self.sampling_rate

    print("SpectrogramFrame Information:")
    print(f"  Channels: {self.n_channels}")
    print(f"  Sampling rate: {self.sampling_rate} Hz")
    print(f"  FFT size: {self.n_fft}")
    print(f"  Hop length: {self.hop_length} samples")
    print(f"  Window length: {self.win_length} samples")
    print(f"  Window: {self.window}")
    print(f"  Frequency range: {self.freqs[0]:.1f} - {self.freqs[-1]:.1f} Hz")
    print(f"  Frequency bins: {self.n_freq_bins}")
    print(f"  Frequency resolution (ΔF): {delta_f:.1f} Hz")
    print(f"  Time frames: {self.n_frames}")
    print(f"  Time resolution (ΔT): {delta_t_ms:.1f} ms")
    print(f"  Total duration: {total_duration:.2f} s")
    print(f"  Channel labels: {self.labels}")
    self._print_operation_history()
from_numpy(data, sampling_rate, n_fft, hop_length, win_length=None, window='hann', label=None, metadata=None, operation_history=None, channel_metadata=None, previous=None) classmethod

Create a SpectrogramFrame from a NumPy array.

Parameters:

Name Type Description Default
data NDArrayComplex

NumPy array containing spectrogram data. Shape should be (n_channels, n_freq_bins, n_time_frames) or (n_freq_bins, n_time_frames) for single channel.

required
sampling_rate float

The sampling rate in Hz.

required
n_fft int

The FFT size used to generate this spectrogram.

required
hop_length int

Number of samples between successive frames.

required
win_length int | None

The window length in samples. If None, defaults to n_fft.

None
window str

The window function used (e.g., "hann", "hamming").

'hann'
label str | None

A label for the frame.

None
metadata dict[str, Any] | None

Optional metadata dictionary.

None
operation_history list[dict[str, Any]] | None

History of operations applied to the frame.

None
channel_metadata list[ChannelMetadata] | list[dict[str, Any]] | None

Metadata for each channel.

None
previous Optional[BaseFrame[Any]]

Reference to the previous frame in the processing chain.

None

Returns:

Type Description
SpectrogramFrame

A new SpectrogramFrame containing the NumPy data.

Source code in wandas/frames/spectrogram.py
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
@classmethod
def from_numpy(
    cls,
    data: NDArrayComplex,
    sampling_rate: float,
    n_fft: int,
    hop_length: int,
    win_length: int | None = None,
    window: str = "hann",
    label: str | None = None,
    metadata: dict[str, Any] | None = None,
    operation_history: list[dict[str, Any]] | None = None,
    channel_metadata: list[ChannelMetadata] | list[dict[str, Any]] | None = None,
    previous: Optional["BaseFrame[Any]"] = None,
) -> "SpectrogramFrame":
    """Create a SpectrogramFrame from a NumPy array.

    Args:
        data: NumPy array containing spectrogram data.
            Shape should be (n_channels, n_freq_bins, n_time_frames) or
            (n_freq_bins, n_time_frames) for single channel.
        sampling_rate: The sampling rate in Hz.
        n_fft: The FFT size used to generate this spectrogram.
        hop_length: Number of samples between successive frames.
        win_length: The window length in samples. If None, defaults to n_fft.
        window: The window function used (e.g., "hann", "hamming").
        label: A label for the frame.
        metadata: Optional metadata dictionary.
        operation_history: History of operations applied to the frame.
        channel_metadata: Metadata for each channel.
        previous: Reference to the previous frame in the processing chain.

    Returns:
        A new SpectrogramFrame containing the NumPy data.
    """

    # Convert NumPy array to dask array
    dask_data = da.from_array(data)
    sf = cls(
        data=dask_data,
        sampling_rate=sampling_rate,
        n_fft=n_fft,
        hop_length=hop_length,
        win_length=win_length,
        window=window,
        label=label or "numpy_spectrogram",
        metadata=metadata,
        operation_history=operation_history,
        channel_metadata=channel_metadata,
        previous=previous,
    )
    return sf

処理モジュール

処理モジュールはオーディオデータに対する様々な処理機能を提供します。

wandas.processing

Audio time series processing operations.

This module provides audio processing operations for time series data.

Attributes

__all__ = ['AudioOperation', '_OPERATION_REGISTRY', 'create_operation', 'get_operation', 'register_operation', 'AWeighting', 'HighPassFilter', 'LowPassFilter', 'CSD', 'Coherence', 'FFT', 'IFFT', 'ISTFT', 'NOctSpectrum', 'NOctSynthesis', 'STFT', 'TransferFunction', 'Welch', 'ReSampling', 'RmsTrend', 'Trim', 'AddWithSNR', 'HpssHarmonic', 'HpssPercussive', 'ABS', 'ChannelDifference', 'Mean', 'Power', 'Sum', 'LoudnessZwst', 'LoudnessZwtv'] module-attribute

Classes

AudioOperation

Bases: Generic[InputArrayType, OutputArrayType]

Abstract base class for audio processing operations.

Source code in wandas/processing/base.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
class AudioOperation(Generic[InputArrayType, OutputArrayType]):
    """Abstract base class for audio processing operations."""

    # Class variable: operation name
    name: ClassVar[str]

    def __init__(self, sampling_rate: float, *, pure: bool = True, **params: Any):
        """
        Initialize AudioOperation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        pure : bool, default=True
            Whether the operation is pure (deterministic with no side effects).
            When True, Dask can cache results for identical inputs.
            Set to False only if the operation has side effects or is non-deterministic.
        **params : Any
            Operation-specific parameters
        """
        self.sampling_rate = sampling_rate
        self.pure = pure
        self.params = params

        # Validate parameters during initialization
        self.validate_params()

        # Create processor function (lazy initialization possible)
        self._setup_processor()

        logger.debug(
            f"Initialized {self.__class__.__name__} operation with params: {params}"
        )

    def validate_params(self) -> None:
        """Validate parameters (raises exception if invalid)"""
        pass

    def _setup_processor(self) -> None:
        """Set up processor function (implemented by subclasses)"""
        pass

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Get metadata updates to apply after processing.

        This method allows operations to specify how metadata should be
        updated after processing. By default, no metadata is updated.

        Returns
        -------
        dict
            Dictionary of metadata updates. Can include:
            - 'sampling_rate': New sampling rate (float)
            - Other metadata keys as needed

        Examples
        --------
        Return empty dict for operations that don't change metadata:

        >>> return {}

        Return new sampling rate for operations that resample:

        >>> return {"sampling_rate": self.target_sr}

        Notes
        -----
        This method is called by the framework after processing to update
        the frame metadata. Subclasses should override this method if they
        need to update metadata (e.g., changing sampling rate).

        Design principle: Operations should use parameters provided at
        initialization (via __init__). All necessary information should be
        available as instance variables.
        """
        return {}

    def get_display_name(self) -> str | None:
        """
        Get display name for the operation for use in channel labels.

        This method allows operations to customize how they appear in
        channel labels. By default, returns None, which means the
        operation name will be used.

        Returns
        -------
        str or None
            Display name for the operation. If None, the operation name
            (from the `name` class variable) is used.

        Examples
        --------
        Default behavior (returns None, uses operation name):

        >>> class NormalizeOp(AudioOperation):
        ...     name = "normalize"
        >>> op = NormalizeOp(44100)
        >>> op.get_display_name()  # Returns None
        >>> # Channel label: "normalize(ch0)"

        Custom display name:

        >>> class LowPassFilter(AudioOperation):
        ...     name = "lowpass_filter"
        ...
        ...     def __init__(self, sr, cutoff):
        ...         self.cutoff = cutoff
        ...         super().__init__(sr, cutoff=cutoff)
        ...
        ...     def get_display_name(self):
        ...         return f"lpf_{self.cutoff}Hz"
        >>> op = LowPassFilter(44100, cutoff=1000)
        >>> op.get_display_name()  # Returns "lpf_1000Hz"
        >>> # Channel label: "lpf_1000Hz(ch0)"

        Notes
        -----
        Subclasses can override this method to provide operation-specific
        display names that include parameter information, making labels
        more informative.
        """
        return None

    def _process_array(self, x: InputArrayType) -> OutputArrayType:
        """Processing function (implemented by subclasses)"""
        # Default is no-op function
        raise NotImplementedError("Subclasses must implement this method.")

    def _create_named_wrapper(self) -> Any:
        """
        Create a named wrapper function for better Dask graph visualization.

        Returns
        -------
        callable
            A wrapper function with the operation name set as __name__.
        """

        def operation_wrapper(x: InputArrayType) -> OutputArrayType:
            return self._process_array(x)

        # Set the function name to the operation name for better visualization
        operation_wrapper.__name__ = self.name
        return operation_wrapper

    def process_array(self, x: InputArrayType) -> Any:
        """
        Processing function wrapped with @dask.delayed.

        This method returns a Delayed object that can be computed later.
        The operation name is used in the Dask task graph for better visualization.

        Parameters
        ----------
        x : InputArrayType
            Input array to process.

        Returns
        -------
        dask.delayed.Delayed
            A Delayed object representing the computation.
        """
        logger.debug(f"Creating delayed operation on data with shape: {x.shape}")
        # Create wrapper with operation name and wrap it with dask.delayed
        wrapper = self._create_named_wrapper()
        delayed_func = delayed(wrapper, pure=self.pure)
        return delayed_func(x)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation.

        This method can be overridden by subclasses for efficiency.
        If not overridden, it will execute _process_array on a small test array
        to determine the output shape.

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape

        Notes
        -----
        The default implementation creates a minimal test array and processes it
        to determine output shape. For performance-critical code, subclasses should
        override this method with a direct calculation.
        """
        # Try to infer shape by executing _process_array on test data
        import numpy as np

        try:
            # Create minimal test array with input shape
            if len(input_shape) == 0:
                return input_shape

            # Create test input with correct dtype
            # Try complex first, fall back to float if needed
            test_input: Any = np.zeros(input_shape, dtype=np.complex128)

            # Process test input
            test_output: Any = self._process_array(test_input)

            # Return the shape of the output
            if isinstance(test_output, np.ndarray):
                return tuple(int(s) for s in test_output.shape)
            return input_shape
        except Exception as e:
            logger.warning(
                f"Failed to infer output shape for {self.__class__.__name__}: {e}. "
                "Please implement calculate_output_shape method."
            )
            raise NotImplementedError(
                f"Subclass {self.__class__.__name__} must implement "
                f"calculate_output_shape or ensure _process_array can be "
                f"called with test data."
            ) from e

    def process(self, data: DaArray) -> DaArray:
        """
        Execute operation and return result
        data shape is (channels, samples)
        """
        # Add task as delayed processing with custom name for visualization
        logger.debug("Adding delayed operation to computation graph")

        # Create a wrapper function with the operation name
        # This allows Dask to use the operation name in the task graph
        wrapper = self._create_named_wrapper()
        delayed_func = delayed(wrapper, pure=self.pure)
        delayed_result = delayed_func(data)

        # Convert delayed result to dask array and return
        output_shape = self.calculate_output_shape(data.shape)
        return _da_from_delayed(delayed_result, shape=output_shape, dtype=data.dtype)
Attributes
name class-attribute
sampling_rate = sampling_rate instance-attribute
pure = pure instance-attribute
params = params instance-attribute
Functions
__init__(sampling_rate, *, pure=True, **params)

Initialize AudioOperation.

Parameters

sampling_rate : float Sampling rate (Hz) pure : bool, default=True Whether the operation is pure (deterministic with no side effects). When True, Dask can cache results for identical inputs. Set to False only if the operation has side effects or is non-deterministic. **params : Any Operation-specific parameters

Source code in wandas/processing/base.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(self, sampling_rate: float, *, pure: bool = True, **params: Any):
    """
    Initialize AudioOperation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    pure : bool, default=True
        Whether the operation is pure (deterministic with no side effects).
        When True, Dask can cache results for identical inputs.
        Set to False only if the operation has side effects or is non-deterministic.
    **params : Any
        Operation-specific parameters
    """
    self.sampling_rate = sampling_rate
    self.pure = pure
    self.params = params

    # Validate parameters during initialization
    self.validate_params()

    # Create processor function (lazy initialization possible)
    self._setup_processor()

    logger.debug(
        f"Initialized {self.__class__.__name__} operation with params: {params}"
    )
validate_params()

Validate parameters (raises exception if invalid)

Source code in wandas/processing/base.py
55
56
57
def validate_params(self) -> None:
    """Validate parameters (raises exception if invalid)"""
    pass
get_metadata_updates()

Get metadata updates to apply after processing.

This method allows operations to specify how metadata should be updated after processing. By default, no metadata is updated.

Returns

dict Dictionary of metadata updates. Can include: - 'sampling_rate': New sampling rate (float) - Other metadata keys as needed

Examples

Return empty dict for operations that don't change metadata:

return {}

Return new sampling rate for operations that resample:

return {"sampling_rate": self.target_sr}

Notes

This method is called by the framework after processing to update the frame metadata. Subclasses should override this method if they need to update metadata (e.g., changing sampling rate).

Design principle: Operations should use parameters provided at initialization (via init). All necessary information should be available as instance variables.

Source code in wandas/processing/base.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Get metadata updates to apply after processing.

    This method allows operations to specify how metadata should be
    updated after processing. By default, no metadata is updated.

    Returns
    -------
    dict
        Dictionary of metadata updates. Can include:
        - 'sampling_rate': New sampling rate (float)
        - Other metadata keys as needed

    Examples
    --------
    Return empty dict for operations that don't change metadata:

    >>> return {}

    Return new sampling rate for operations that resample:

    >>> return {"sampling_rate": self.target_sr}

    Notes
    -----
    This method is called by the framework after processing to update
    the frame metadata. Subclasses should override this method if they
    need to update metadata (e.g., changing sampling rate).

    Design principle: Operations should use parameters provided at
    initialization (via __init__). All necessary information should be
    available as instance variables.
    """
    return {}
get_display_name()

Get display name for the operation for use in channel labels.

This method allows operations to customize how they appear in channel labels. By default, returns None, which means the operation name will be used.

Returns

str or None Display name for the operation. If None, the operation name (from the name class variable) is used.

Examples

Default behavior (returns None, uses operation name):

class NormalizeOp(AudioOperation): ... name = "normalize" op = NormalizeOp(44100) op.get_display_name() # Returns None

Channel label: "normalize(ch0)"

Custom display name:

class LowPassFilter(AudioOperation): ... name = "lowpass_filter" ... ... def init(self, sr, cutoff): ... self.cutoff = cutoff ... super().init(sr, cutoff=cutoff) ... ... def get_display_name(self): ... return f"lpf_{self.cutoff}Hz" op = LowPassFilter(44100, cutoff=1000) op.get_display_name() # Returns "lpf_1000Hz"

Channel label: "lpf_1000Hz(ch0)"
Notes

Subclasses can override this method to provide operation-specific display names that include parameter information, making labels more informative.

Source code in wandas/processing/base.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def get_display_name(self) -> str | None:
    """
    Get display name for the operation for use in channel labels.

    This method allows operations to customize how they appear in
    channel labels. By default, returns None, which means the
    operation name will be used.

    Returns
    -------
    str or None
        Display name for the operation. If None, the operation name
        (from the `name` class variable) is used.

    Examples
    --------
    Default behavior (returns None, uses operation name):

    >>> class NormalizeOp(AudioOperation):
    ...     name = "normalize"
    >>> op = NormalizeOp(44100)
    >>> op.get_display_name()  # Returns None
    >>> # Channel label: "normalize(ch0)"

    Custom display name:

    >>> class LowPassFilter(AudioOperation):
    ...     name = "lowpass_filter"
    ...
    ...     def __init__(self, sr, cutoff):
    ...         self.cutoff = cutoff
    ...         super().__init__(sr, cutoff=cutoff)
    ...
    ...     def get_display_name(self):
    ...         return f"lpf_{self.cutoff}Hz"
    >>> op = LowPassFilter(44100, cutoff=1000)
    >>> op.get_display_name()  # Returns "lpf_1000Hz"
    >>> # Channel label: "lpf_1000Hz(ch0)"

    Notes
    -----
    Subclasses can override this method to provide operation-specific
    display names that include parameter information, making labels
    more informative.
    """
    return None
process_array(x)

Processing function wrapped with @dask.delayed.

This method returns a Delayed object that can be computed later. The operation name is used in the Dask task graph for better visualization.

Parameters

x : InputArrayType Input array to process.

Returns

dask.delayed.Delayed A Delayed object representing the computation.

Source code in wandas/processing/base.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def process_array(self, x: InputArrayType) -> Any:
    """
    Processing function wrapped with @dask.delayed.

    This method returns a Delayed object that can be computed later.
    The operation name is used in the Dask task graph for better visualization.

    Parameters
    ----------
    x : InputArrayType
        Input array to process.

    Returns
    -------
    dask.delayed.Delayed
        A Delayed object representing the computation.
    """
    logger.debug(f"Creating delayed operation on data with shape: {x.shape}")
    # Create wrapper with operation name and wrap it with dask.delayed
    wrapper = self._create_named_wrapper()
    delayed_func = delayed(wrapper, pure=self.pure)
    return delayed_func(x)
calculate_output_shape(input_shape)

Calculate output data shape after operation.

This method can be overridden by subclasses for efficiency. If not overridden, it will execute _process_array on a small test array to determine the output shape.

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Notes

The default implementation creates a minimal test array and processes it to determine output shape. For performance-critical code, subclasses should override this method with a direct calculation.

Source code in wandas/processing/base.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation.

    This method can be overridden by subclasses for efficiency.
    If not overridden, it will execute _process_array on a small test array
    to determine the output shape.

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape

    Notes
    -----
    The default implementation creates a minimal test array and processes it
    to determine output shape. For performance-critical code, subclasses should
    override this method with a direct calculation.
    """
    # Try to infer shape by executing _process_array on test data
    import numpy as np

    try:
        # Create minimal test array with input shape
        if len(input_shape) == 0:
            return input_shape

        # Create test input with correct dtype
        # Try complex first, fall back to float if needed
        test_input: Any = np.zeros(input_shape, dtype=np.complex128)

        # Process test input
        test_output: Any = self._process_array(test_input)

        # Return the shape of the output
        if isinstance(test_output, np.ndarray):
            return tuple(int(s) for s in test_output.shape)
        return input_shape
    except Exception as e:
        logger.warning(
            f"Failed to infer output shape for {self.__class__.__name__}: {e}. "
            "Please implement calculate_output_shape method."
        )
        raise NotImplementedError(
            f"Subclass {self.__class__.__name__} must implement "
            f"calculate_output_shape or ensure _process_array can be "
            f"called with test data."
        ) from e
process(data)

Execute operation and return result data shape is (channels, samples)

Source code in wandas/processing/base.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
def process(self, data: DaArray) -> DaArray:
    """
    Execute operation and return result
    data shape is (channels, samples)
    """
    # Add task as delayed processing with custom name for visualization
    logger.debug("Adding delayed operation to computation graph")

    # Create a wrapper function with the operation name
    # This allows Dask to use the operation name in the task graph
    wrapper = self._create_named_wrapper()
    delayed_func = delayed(wrapper, pure=self.pure)
    delayed_result = delayed_func(data)

    # Convert delayed result to dask array and return
    output_shape = self.calculate_output_shape(data.shape)
    return _da_from_delayed(delayed_result, shape=output_shape, dtype=data.dtype)

AddWithSNR

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Addition operation considering SNR

Source code in wandas/processing/effects.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
class AddWithSNR(AudioOperation[NDArrayReal, NDArrayReal]):
    """Addition operation considering SNR"""

    name = "add_with_snr"

    def __init__(self, sampling_rate: float, other: DaArray, snr: float = 1.0):
        """
        Initialize addition operation considering SNR

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        other : DaArray
            Noise signal to add (channel-frame format)
        snr : float
            Signal-to-noise ratio (dB)
        """
        super().__init__(sampling_rate, other=other, snr=snr)

        self.other = other
        self.snr = snr
        logger.debug(f"Initialized AddWithSNR operation with SNR: {snr} dB")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape (same as input)
        """
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "+SNR"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Perform addition processing considering SNR"""
        logger.debug(f"Applying SNR-based addition with shape: {x.shape}")
        other: NDArrayReal = self.other.compute()

        # Use multi-channel versions of calculate_rms and calculate_desired_noise_rms
        clean_rms = util.calculate_rms(x)
        other_rms = util.calculate_rms(other)

        # Adjust noise gain based on specified SNR (apply per channel)
        desired_noise_rms = util.calculate_desired_noise_rms(clean_rms, self.snr)

        # Apply gain per channel using broadcasting
        gain = desired_noise_rms / other_rms
        # Add adjusted noise to signal
        result: NDArrayReal = x + other * gain
        return result
Attributes
name = 'add_with_snr' class-attribute instance-attribute
other = other instance-attribute
snr = snr instance-attribute
Functions
__init__(sampling_rate, other, snr=1.0)

Initialize addition operation considering SNR

Parameters

sampling_rate : float Sampling rate (Hz) other : DaArray Noise signal to add (channel-frame format) snr : float Signal-to-noise ratio (dB)

Source code in wandas/processing/effects.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def __init__(self, sampling_rate: float, other: DaArray, snr: float = 1.0):
    """
    Initialize addition operation considering SNR

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    other : DaArray
        Noise signal to add (channel-frame format)
    snr : float
        Signal-to-noise ratio (dB)
    """
    super().__init__(sampling_rate, other=other, snr=snr)

    self.other = other
    self.snr = snr
    logger.debug(f"Initialized AddWithSNR operation with SNR: {snr} dB")
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape (same as input)

Source code in wandas/processing/effects.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape (same as input)
    """
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
321
322
323
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "+SNR"

HpssHarmonic

Bases: AudioOperation[NDArrayReal, NDArrayReal]

HPSS Harmonic operation

Source code in wandas/processing/effects.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class HpssHarmonic(AudioOperation[NDArrayReal, NDArrayReal]):
    """HPSS Harmonic operation"""

    name = "hpss_harmonic"

    def __init__(
        self,
        sampling_rate: float,
        **kwargs: Any,
    ):
        """
        Initialize HPSS Harmonic

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        self.kwargs = kwargs
        super().__init__(sampling_rate, **kwargs)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Hrm"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for HPSS Harmonic"""
        logger.debug(f"Applying HPSS Harmonic to array with shape: {x.shape}")
        result: NDArrayReal = effects.harmonic(x, **self.kwargs)
        logger.debug(
            f"HPSS Harmonic applied, returning result with shape: {result.shape}"
        )
        return result
Attributes
name = 'hpss_harmonic' class-attribute instance-attribute
kwargs = kwargs instance-attribute
Functions
__init__(sampling_rate, **kwargs)

Initialize HPSS Harmonic

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/effects.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def __init__(
    self,
    sampling_rate: float,
    **kwargs: Any,
):
    """
    Initialize HPSS Harmonic

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    self.kwargs = kwargs
    super().__init__(sampling_rate, **kwargs)
calculate_output_shape(input_shape)
Source code in wandas/processing/effects.py
38
39
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
41
42
43
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Hrm"

HpssPercussive

Bases: AudioOperation[NDArrayReal, NDArrayReal]

HPSS Percussive operation

Source code in wandas/processing/effects.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class HpssPercussive(AudioOperation[NDArrayReal, NDArrayReal]):
    """HPSS Percussive operation"""

    name = "hpss_percussive"

    def __init__(
        self,
        sampling_rate: float,
        **kwargs: Any,
    ):
        """
        Initialize HPSS Percussive

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        self.kwargs = kwargs
        super().__init__(sampling_rate, **kwargs)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Prc"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for HPSS Percussive"""
        logger.debug(f"Applying HPSS Percussive to array with shape: {x.shape}")
        result: NDArrayReal = effects.percussive(x, **self.kwargs)
        logger.debug(
            f"HPSS Percussive applied, returning result with shape: {result.shape}"
        )
        return result
Attributes
name = 'hpss_percussive' class-attribute instance-attribute
kwargs = kwargs instance-attribute
Functions
__init__(sampling_rate, **kwargs)

Initialize HPSS Percussive

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/effects.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def __init__(
    self,
    sampling_rate: float,
    **kwargs: Any,
):
    """
    Initialize HPSS Percussive

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    self.kwargs = kwargs
    super().__init__(sampling_rate, **kwargs)
calculate_output_shape(input_shape)
Source code in wandas/processing/effects.py
76
77
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
79
80
81
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Prc"

AWeighting

Bases: AudioOperation[NDArrayReal, NDArrayReal]

A-weighting filter operation

Source code in wandas/processing/filters.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
class AWeighting(AudioOperation[NDArrayReal, NDArrayReal]):
    """A-weighting filter operation"""

    name = "a_weighting"

    def __init__(self, sampling_rate: float):
        """
        Initialize A-weighting filter

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        super().__init__(sampling_rate)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Aw"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for A-weighting filter"""
        logger.debug(f"Applying A-weighting to array with shape: {x.shape}")
        result = A_weight(x, self.sampling_rate)

        # Handle case where A_weight returns a tuple
        if isinstance(result, tuple):
            # Use the first element of the tuple
            result = result[0]

        logger.debug(
            f"A-weighting applied, returning result with shape: {result.shape}"
        )
        return np.array(result)
Attributes
name = 'a_weighting' class-attribute instance-attribute
Functions
__init__(sampling_rate)

Initialize A-weighting filter

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/filters.py
266
267
268
269
270
271
272
273
274
275
def __init__(self, sampling_rate: float):
    """
    Initialize A-weighting filter

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    super().__init__(sampling_rate)
calculate_output_shape(input_shape)
Source code in wandas/processing/filters.py
277
278
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/filters.py
280
281
282
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Aw"

HighPassFilter

Bases: AudioOperation[NDArrayReal, NDArrayReal]

High-pass filter operation

Source code in wandas/processing/filters.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class HighPassFilter(AudioOperation[NDArrayReal, NDArrayReal]):
    """High-pass filter operation"""

    name = "highpass_filter"
    a: NDArrayReal
    b: NDArrayReal

    def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
        """
        Initialize high-pass filter

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        cutoff : float
            Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
            (sampling_rate / 2).
        order : int, optional
            Filter order, default is 4

        Raises
        ------
        ValueError
            If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
        """
        self.cutoff = cutoff
        self.order = order
        super().__init__(sampling_rate, cutoff=cutoff, order=order)

    def validate_params(self) -> None:
        """Validate parameters"""
        nyquist = self.sampling_rate / 2
        if self.cutoff <= 0 or self.cutoff >= nyquist:
            raise ValueError(
                f"Cutoff frequency out of valid range\n"
                f"  Got: {self.cutoff} Hz\n"
                f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
                f"The Nyquist frequency is half the sampling rate\n"
                f"  ({self.sampling_rate} Hz).\n"
                f"Filters cannot work above this limit due to aliasing.\n"
                f"Solutions:\n"
                f"  - Use a cutoff frequency below {nyquist} Hz\n"
                f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
                f"    using resample()"
            )

    def _setup_processor(self) -> None:
        """Set up high-pass filter processor"""
        # Calculate filter coefficients (once) - safely retrieve from instance variables
        nyquist = 0.5 * self.sampling_rate
        normal_cutoff = self.cutoff / nyquist

        # Precompute and save filter coefficients
        self.b, self.a = signal.butter(self.order, normal_cutoff, btype="high")  # type: ignore [unused-ignore]
        logger.debug(f"Highpass filter coefficients calculated: b={self.b}, a={self.a}")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "hpf"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Filter processing wrapped with @dask.delayed"""
        logger.debug(f"Applying highpass filter to array with shape: {x.shape}")
        result: NDArrayReal = signal.filtfilt(self.b, self.a, x, axis=1)
        logger.debug(f"Filter applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'highpass_filter' class-attribute instance-attribute
a instance-attribute
b instance-attribute
cutoff = cutoff instance-attribute
order = order instance-attribute
Functions
__init__(sampling_rate, cutoff, order=4)

Initialize high-pass filter

Parameters

sampling_rate : float Sampling rate (Hz) cutoff : float Cutoff frequency (Hz). Must be between 0 and Nyquist frequency (sampling_rate / 2). order : int, optional Filter order, default is 4

Raises

ValueError If cutoff frequency is not within valid range (0 < cutoff < Nyquist)

Source code in wandas/processing/filters.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
    """
    Initialize high-pass filter

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    cutoff : float
        Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
        (sampling_rate / 2).
    order : int, optional
        Filter order, default is 4

    Raises
    ------
    ValueError
        If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
    """
    self.cutoff = cutoff
    self.order = order
    super().__init__(sampling_rate, cutoff=cutoff, order=order)
validate_params()

Validate parameters

Source code in wandas/processing/filters.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def validate_params(self) -> None:
    """Validate parameters"""
    nyquist = self.sampling_rate / 2
    if self.cutoff <= 0 or self.cutoff >= nyquist:
        raise ValueError(
            f"Cutoff frequency out of valid range\n"
            f"  Got: {self.cutoff} Hz\n"
            f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
            f"The Nyquist frequency is half the sampling rate\n"
            f"  ({self.sampling_rate} Hz).\n"
            f"Filters cannot work above this limit due to aliasing.\n"
            f"Solutions:\n"
            f"  - Use a cutoff frequency below {nyquist} Hz\n"
            f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
            f"    using resample()"
        )
calculate_output_shape(input_shape)
Source code in wandas/processing/filters.py
70
71
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/filters.py
73
74
75
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "hpf"

LowPassFilter

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Low-pass filter operation

Source code in wandas/processing/filters.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
class LowPassFilter(AudioOperation[NDArrayReal, NDArrayReal]):
    """Low-pass filter operation"""

    name = "lowpass_filter"
    a: NDArrayReal
    b: NDArrayReal

    def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
        """
        Initialize low-pass filter

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        cutoff : float
            Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
            (sampling_rate / 2).
        order : int, optional
            Filter order, default is 4

        Raises
        ------
        ValueError
            If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
        """
        self.cutoff = cutoff
        self.order = order
        super().__init__(sampling_rate, cutoff=cutoff, order=order)

    def validate_params(self) -> None:
        """Validate parameters"""
        nyquist = self.sampling_rate / 2
        if self.cutoff <= 0 or self.cutoff >= nyquist:
            raise ValueError(
                f"Cutoff frequency out of valid range\n"
                f"  Got: {self.cutoff} Hz\n"
                f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
                f"The Nyquist frequency is half the sampling rate\n"
                f"  ({self.sampling_rate} Hz).\n"
                f"Filters cannot work above this limit due to aliasing.\n"
                f"Solutions:\n"
                f"  - Use a cutoff frequency below {nyquist} Hz\n"
                f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
                f"    using resample()"
            )

    def _setup_processor(self) -> None:
        """Set up low-pass filter processor"""
        nyquist = 0.5 * self.sampling_rate
        normal_cutoff = self.cutoff / nyquist

        # Precompute and save filter coefficients
        self.b, self.a = signal.butter(self.order, normal_cutoff, btype="low")  # type: ignore [unused-ignore]
        logger.debug(f"Lowpass filter coefficients calculated: b={self.b}, a={self.a}")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "lpf"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Filter processing wrapped with @dask.delayed"""
        logger.debug(f"Applying lowpass filter to array with shape: {x.shape}")
        result: NDArrayReal = signal.filtfilt(self.b, self.a, x, axis=1)

        logger.debug(f"Filter applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'lowpass_filter' class-attribute instance-attribute
a instance-attribute
b instance-attribute
cutoff = cutoff instance-attribute
order = order instance-attribute
Functions
__init__(sampling_rate, cutoff, order=4)

Initialize low-pass filter

Parameters

sampling_rate : float Sampling rate (Hz) cutoff : float Cutoff frequency (Hz). Must be between 0 and Nyquist frequency (sampling_rate / 2). order : int, optional Filter order, default is 4

Raises

ValueError If cutoff frequency is not within valid range (0 < cutoff < Nyquist)

Source code in wandas/processing/filters.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
    """
    Initialize low-pass filter

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    cutoff : float
        Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
        (sampling_rate / 2).
    order : int, optional
        Filter order, default is 4

    Raises
    ------
    ValueError
        If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
    """
    self.cutoff = cutoff
    self.order = order
    super().__init__(sampling_rate, cutoff=cutoff, order=order)
validate_params()

Validate parameters

Source code in wandas/processing/filters.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def validate_params(self) -> None:
    """Validate parameters"""
    nyquist = self.sampling_rate / 2
    if self.cutoff <= 0 or self.cutoff >= nyquist:
        raise ValueError(
            f"Cutoff frequency out of valid range\n"
            f"  Got: {self.cutoff} Hz\n"
            f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
            f"The Nyquist frequency is half the sampling rate\n"
            f"  ({self.sampling_rate} Hz).\n"
            f"Filters cannot work above this limit due to aliasing.\n"
            f"Solutions:\n"
            f"  - Use a cutoff frequency below {nyquist} Hz\n"
            f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
            f"    using resample()"
        )
calculate_output_shape(input_shape)
Source code in wandas/processing/filters.py
141
142
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/filters.py
144
145
146
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "lpf"

LoudnessZwst

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

This operation computes the loudness of stationary (steady) signals according to the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's implementation of the standardized loudness calculation for steady signals.

The loudness is calculated in sones, a unit of perceived loudness where a doubling of sones corresponds to a doubling of perceived loudness.

Parameters

sampling_rate : float Sampling rate in Hz. The signal should be sampled at a rate appropriate for the analysis (typically 44100 Hz or 48000 Hz for audio). field_type : str, default="free" Type of sound field. Options: - 'free': Free field (sound arriving from a specific direction) - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

Attributes

name : str Operation name: "loudness_zwst" field_type : str The sound field type used for calculation

Examples

Calculate steady-state loudness for a signal:

import wandas as wd signal = wd.read_wav("fan_noise.wav") loudness = signal.loudness_zwst(field_type="free") print(f"Steady-state loudness: {loudness.data[0]:.2f} sones")

Notes
  • The output contains a single loudness value in sones for each channel
  • For mono signals, the loudness is calculated directly
  • For multi-channel signals, loudness is calculated per channel
  • The method follows ISO 532-1:2017 standard for steady-state loudness
  • Typical loudness values: 1 sone ≈ 40 phon (loudness level)
  • This method is suitable for stationary signals such as fan noise, constant machinery sounds, or other steady sounds
References

.. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method" .. [2] MoSQITo documentation: https://mosqito.readthedocs.io/en/latest/

Source code in wandas/processing/psychoacoustic.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
class LoudnessZwst(AudioOperation[NDArrayReal, NDArrayReal]):
    """
    Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

    This operation computes the loudness of stationary (steady) signals according to
    the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's
    implementation of the standardized loudness calculation for steady signals.

    The loudness is calculated in sones, a unit of perceived loudness where a doubling
    of sones corresponds to a doubling of perceived loudness.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate in Hz. The signal should be sampled at a rate appropriate
        for the analysis (typically 44100 Hz or 48000 Hz for audio).
    field_type : str, default="free"
        Type of sound field. Options:
        - 'free': Free field (sound arriving from a specific direction)
        - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

    Attributes
    ----------
    name : str
        Operation name: "loudness_zwst"
    field_type : str
        The sound field type used for calculation

    Examples
    --------
    Calculate steady-state loudness for a signal:
    >>> import wandas as wd
    >>> signal = wd.read_wav("fan_noise.wav")
    >>> loudness = signal.loudness_zwst(field_type="free")
    >>> print(f"Steady-state loudness: {loudness.data[0]:.2f} sones")

    Notes
    -----
    - The output contains a single loudness value in sones for each channel
    - For mono signals, the loudness is calculated directly
    - For multi-channel signals, loudness is calculated per channel
    - The method follows ISO 532-1:2017 standard for steady-state loudness
    - Typical loudness values: 1 sone ≈ 40 phon (loudness level)
    - This method is suitable for stationary signals such as fan noise,
      constant machinery sounds, or other steady sounds

    References
    ----------
    .. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
           Part 1: Zwicker method"
    .. [2] MoSQITo documentation:
           https://mosqito.readthedocs.io/en/latest/
    """

    name = "loudness_zwst"

    def __init__(self, sampling_rate: float, field_type: str = "free"):
        """
        Initialize steady-state loudness calculation operation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        field_type : str, default="free"
            Type of sound field ('free' or 'diffuse')
        """
        self.field_type = field_type
        super().__init__(sampling_rate, field_type=field_type)

    def validate_params(self) -> None:
        """
        Validate parameters.

        Raises
        ------
        ValueError
            If field_type is not 'free' or 'diffuse'
        """
        if self.field_type not in ("free", "diffuse"):
            raise ValueError(
                f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
            )

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Get metadata updates to apply after processing.

        For steady-state loudness, the output is a single value per channel,
        so no sampling rate update is needed (output is scalar, not time-series).

        Returns
        -------
        dict
            Empty dictionary (no metadata updates needed)

        Notes
        -----
        Unlike time-varying loudness, steady-state loudness produces a single
        value, not a time series, so the sampling rate concept doesn't apply.
        """
        return {}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation.

        The steady-state loudness calculation produces a single loudness value
        per channel.

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape: (channels, 1) - one loudness value per channel
        """
        n_channels = input_shape[0] if len(input_shape) > 1 else 1
        return (n_channels, 1)

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """
        Process array to calculate steady-state loudness.

        This method calculates the steady-state loudness for each channel
        of the input signal using the Zwicker method.

        Parameters
        ----------
        x : NDArrayReal
            Input signal array with shape (channels, samples) or (samples,)

        Returns
        -------
        NDArrayReal
            Steady-state loudness in sones for each channel.
            Shape: (channels, 1)

        Notes
        -----
        The function processes each channel independently and returns
        a single loudness value per channel.
        """
        logger.debug(
            f"Calculating steady-state loudness for signal with shape: {x.shape}, "
            f"field_type: {self.field_type}"
        )

        # Handle 1D input (single channel)
        if x.ndim == 1:
            x = x.reshape(1, -1)

        n_channels = x.shape[0]
        loudness_results = []

        for ch in range(n_channels):
            channel_data = x[ch, :]

            # Ensure channel_data is a contiguous 1D NumPy array
            channel_data = np.asarray(channel_data).ravel()

            # Call MoSQITo's loudness_zwst function
            # Returns: N (single loudness value), N_spec (specific loudness),
            #          bark_axis
            loudness_n, _, _ = loudness_zwst_mosqito(
                channel_data, self.sampling_rate, field_type=self.field_type
            )

            loudness_results.append(loudness_n)

            logger.debug(
                f"Channel {ch}: Calculated steady-state loudness: "
                f"{loudness_n:.2f} sones"
            )

        # Stack results and reshape to (channels, 1)
        result: NDArrayReal = np.array(loudness_results).reshape(n_channels, 1)

        logger.debug(
            f"Steady-state loudness calculation complete, output shape: {result.shape}"
        )
        return result
Attributes
name = 'loudness_zwst' class-attribute instance-attribute
field_type = field_type instance-attribute
Functions
__init__(sampling_rate, field_type='free')

Initialize steady-state loudness calculation operation.

Parameters

sampling_rate : float Sampling rate (Hz) field_type : str, default="free" Type of sound field ('free' or 'diffuse')

Source code in wandas/processing/psychoacoustic.py
277
278
279
280
281
282
283
284
285
286
287
288
289
def __init__(self, sampling_rate: float, field_type: str = "free"):
    """
    Initialize steady-state loudness calculation operation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    field_type : str, default="free"
        Type of sound field ('free' or 'diffuse')
    """
    self.field_type = field_type
    super().__init__(sampling_rate, field_type=field_type)
validate_params()

Validate parameters.

Raises

ValueError If field_type is not 'free' or 'diffuse'

Source code in wandas/processing/psychoacoustic.py
291
292
293
294
295
296
297
298
299
300
301
302
303
def validate_params(self) -> None:
    """
    Validate parameters.

    Raises
    ------
    ValueError
        If field_type is not 'free' or 'diffuse'
    """
    if self.field_type not in ("free", "diffuse"):
        raise ValueError(
            f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
        )
get_metadata_updates()

Get metadata updates to apply after processing.

For steady-state loudness, the output is a single value per channel, so no sampling rate update is needed (output is scalar, not time-series).

Returns

dict Empty dictionary (no metadata updates needed)

Notes

Unlike time-varying loudness, steady-state loudness produces a single value, not a time series, so the sampling rate concept doesn't apply.

Source code in wandas/processing/psychoacoustic.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Get metadata updates to apply after processing.

    For steady-state loudness, the output is a single value per channel,
    so no sampling rate update is needed (output is scalar, not time-series).

    Returns
    -------
    dict
        Empty dictionary (no metadata updates needed)

    Notes
    -----
    Unlike time-varying loudness, steady-state loudness produces a single
    value, not a time series, so the sampling rate concept doesn't apply.
    """
    return {}
calculate_output_shape(input_shape)

Calculate output data shape after operation.

The steady-state loudness calculation produces a single loudness value per channel.

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape: (channels, 1) - one loudness value per channel

Source code in wandas/processing/psychoacoustic.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation.

    The steady-state loudness calculation produces a single loudness value
    per channel.

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape: (channels, 1) - one loudness value per channel
    """
    n_channels = input_shape[0] if len(input_shape) > 1 else 1
    return (n_channels, 1)

LoudnessZwtv

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

This operation computes the loudness of non-stationary signals according to the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's implementation of the standardized loudness calculation.

The loudness is calculated in sones, a unit of perceived loudness where a doubling of sones corresponds to a doubling of perceived loudness.

Parameters

sampling_rate : float Sampling rate in Hz. The signal should be sampled at a rate appropriate for the analysis (typically 44100 Hz or 48000 Hz for audio). field_type : str, default="free" Type of sound field. Options: - 'free': Free field (sound arriving from a specific direction) - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

Attributes

name : str Operation name: "loudness_zwtv" field_type : str The sound field type used for calculation

Examples

Calculate loudness for a signal:

import wandas as wd signal = wd.read_wav("audio.wav") loudness = signal.loudness_zwtv(field_type="free")

Notes
  • The output contains time-varying loudness values in sones
  • For mono signals, the loudness is calculated directly
  • For multi-channel signals, loudness is calculated per channel
  • The method follows ISO 532-1:2017 standard for time-varying loudness
  • Typical loudness values: 1 sone ≈ 40 phon (loudness level)
References

.. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method" .. [2] MoSQITo documentation: https://mosqito.readthedocs.io/en/latest/

Source code in wandas/processing/psychoacoustic.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
class LoudnessZwtv(AudioOperation[NDArrayReal, NDArrayReal]):
    """
    Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

    This operation computes the loudness of non-stationary signals according to
    the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's
    implementation of the standardized loudness calculation.

    The loudness is calculated in sones, a unit of perceived loudness where a doubling
    of sones corresponds to a doubling of perceived loudness.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate in Hz. The signal should be sampled at a rate appropriate
        for the analysis (typically 44100 Hz or 48000 Hz for audio).
    field_type : str, default="free"
        Type of sound field. Options:
        - 'free': Free field (sound arriving from a specific direction)
        - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

    Attributes
    ----------
    name : str
        Operation name: "loudness_zwtv"
    field_type : str
        The sound field type used for calculation

    Examples
    --------
    Calculate loudness for a signal:
    >>> import wandas as wd
    >>> signal = wd.read_wav("audio.wav")
    >>> loudness = signal.loudness_zwtv(field_type="free")

    Notes
    -----
    - The output contains time-varying loudness values in sones
    - For mono signals, the loudness is calculated directly
    - For multi-channel signals, loudness is calculated per channel
    - The method follows ISO 532-1:2017 standard for time-varying loudness
    - Typical loudness values: 1 sone ≈ 40 phon (loudness level)

    References
    ----------
    .. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
           Part 1: Zwicker method"
    .. [2] MoSQITo documentation:
           https://mosqito.readthedocs.io/en/latest/
    """

    name = "loudness_zwtv"

    def __init__(self, sampling_rate: float, field_type: str = "free"):
        """
        Initialize Loudness calculation operation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        field_type : str, default="free"
            Type of sound field ('free' or 'diffuse')
        """
        self.field_type = field_type
        super().__init__(sampling_rate, field_type=field_type)

    def validate_params(self) -> None:
        """
        Validate parameters.

        Raises
        ------
        ValueError
            If field_type is not 'free' or 'diffuse'
        """
        if self.field_type not in ("free", "diffuse"):
            raise ValueError(
                f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
            )

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Update sampling rate based on MoSQITo's time resolution.

        The Zwicker method uses approximately 2ms time steps,
        which corresponds to 500 Hz sampling rate, independent of
        the input sampling rate.

        Returns
        -------
        dict
            Metadata updates with new sampling rate

        Notes
        -----
        All necessary parameters are provided at initialization.
        The output sampling rate is always 500 Hz regardless of input.
        """
        return {"sampling_rate": 500.0}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation.

        The loudness calculation produces a time-varying output where the time
        resolution depends on the algorithm's internal processing. The exact
        output length is determined dynamically by the loudness_zwtv function.

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape. For loudness, we return a placeholder shape
            since the actual length is determined by the algorithm.
            The shape will be (channels, time_samples) where time_samples
            depends on the input length and algorithm parameters.
        """
        # Return a placeholder shape - the actual shape will be determined
        # after processing since loudness_zwtv determines the time resolution
        # For now, we estimate based on typical behavior (approx 2ms time steps)
        n_channels = input_shape[0] if len(input_shape) > 1 else 1
        # Rough estimate: one loudness value per 2ms (0.002s)
        estimated_time_samples = int(input_shape[-1] / (self.sampling_rate * 0.002))
        return (n_channels, estimated_time_samples)

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """
        Process array to calculate loudness.

        This method calculates the time-varying loudness for each channel
        of the input signal using the Zwicker method.

        Parameters
        ----------
        x : NDArrayReal
            Input signal array with shape (channels, samples) or (samples,)

        Returns
        -------
        NDArrayReal
            Time-varying loudness in sones for each channel.
            Shape: (channels, time_samples)

        Notes
        -----
        The function processes each channel independently and returns
        the loudness values. The time axis information is not returned
        here but can be reconstructed based on the MoSQITo algorithm's
        behavior (typically 2ms time steps).
        """
        logger.debug(
            f"Calculating loudness for signal with shape: {x.shape}, "
            f"field_type: {self.field_type}"
        )

        # Handle 1D input (single channel)
        if x.ndim == 1:
            x = x.reshape(1, -1)

        n_channels = x.shape[0]
        loudness_results = []

        for ch in range(n_channels):
            channel_data = x[ch, :]

            # Ensure channel_data is a contiguous 1D NumPy array
            channel_data = np.asarray(channel_data).ravel()

            # Call MoSQITo's loudness_zwtv function
            # Returns: N (loudness), N_spec (specific loudness),
            #          bark_axis, time_axis
            loudness_n, _, _, _ = loudness_zwtv_mosqito(
                channel_data, self.sampling_rate, field_type=self.field_type
            )

            loudness_results.append(loudness_n)

            logger.debug(
                f"Channel {ch}: Calculated loudness with "
                f"{len(loudness_n)} time points, "
                f"max loudness: {np.max(loudness_n):.2f} sones"
            )

        # Stack results
        result: NDArrayReal = np.stack(loudness_results, axis=0)

        logger.debug(f"Loudness calculation complete, output shape: {result.shape}")
        return result
Attributes
name = 'loudness_zwtv' class-attribute instance-attribute
field_type = field_type instance-attribute
Functions
__init__(sampling_rate, field_type='free')

Initialize Loudness calculation operation.

Parameters

sampling_rate : float Sampling rate (Hz) field_type : str, default="free" Type of sound field ('free' or 'diffuse')

Source code in wandas/processing/psychoacoustic.py
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(self, sampling_rate: float, field_type: str = "free"):
    """
    Initialize Loudness calculation operation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    field_type : str, default="free"
        Type of sound field ('free' or 'diffuse')
    """
    self.field_type = field_type
    super().__init__(sampling_rate, field_type=field_type)
validate_params()

Validate parameters.

Raises

ValueError If field_type is not 'free' or 'diffuse'

Source code in wandas/processing/psychoacoustic.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def validate_params(self) -> None:
    """
    Validate parameters.

    Raises
    ------
    ValueError
        If field_type is not 'free' or 'diffuse'
    """
    if self.field_type not in ("free", "diffuse"):
        raise ValueError(
            f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
        )
get_metadata_updates()

Update sampling rate based on MoSQITo's time resolution.

The Zwicker method uses approximately 2ms time steps, which corresponds to 500 Hz sampling rate, independent of the input sampling rate.

Returns

dict Metadata updates with new sampling rate

Notes

All necessary parameters are provided at initialization. The output sampling rate is always 500 Hz regardless of input.

Source code in wandas/processing/psychoacoustic.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Update sampling rate based on MoSQITo's time resolution.

    The Zwicker method uses approximately 2ms time steps,
    which corresponds to 500 Hz sampling rate, independent of
    the input sampling rate.

    Returns
    -------
    dict
        Metadata updates with new sampling rate

    Notes
    -----
    All necessary parameters are provided at initialization.
    The output sampling rate is always 500 Hz regardless of input.
    """
    return {"sampling_rate": 500.0}
calculate_output_shape(input_shape)

Calculate output data shape after operation.

The loudness calculation produces a time-varying output where the time resolution depends on the algorithm's internal processing. The exact output length is determined dynamically by the loudness_zwtv function.

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape. For loudness, we return a placeholder shape since the actual length is determined by the algorithm. The shape will be (channels, time_samples) where time_samples depends on the input length and algorithm parameters.

Source code in wandas/processing/psychoacoustic.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation.

    The loudness calculation produces a time-varying output where the time
    resolution depends on the algorithm's internal processing. The exact
    output length is determined dynamically by the loudness_zwtv function.

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape. For loudness, we return a placeholder shape
        since the actual length is determined by the algorithm.
        The shape will be (channels, time_samples) where time_samples
        depends on the input length and algorithm parameters.
    """
    # Return a placeholder shape - the actual shape will be determined
    # after processing since loudness_zwtv determines the time resolution
    # For now, we estimate based on typical behavior (approx 2ms time steps)
    n_channels = input_shape[0] if len(input_shape) > 1 else 1
    # Rough estimate: one loudness value per 2ms (0.002s)
    estimated_time_samples = int(input_shape[-1] / (self.sampling_rate * 0.002))
    return (n_channels, estimated_time_samples)

CSD

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

Cross-spectral density estimation operation

Source code in wandas/processing/spectral.py
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
class CSD(AudioOperation[NDArrayReal, NDArrayComplex]):
    """Cross-spectral density estimation operation"""

    name = "csd"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ):
        """
        Initialize cross-spectral density estimation operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window function, default is 'hann'
        detrend : str
            Type of detrend, default is 'constant'
        scaling : str
            Type of scaling, default is 'spectrum'
        average : str
            Method of averaging, default is 'mean'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "CSD"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.detrend = detrend
        self.scaling = scaling
        self.average = average
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels * channels, freqs)
        """
        n_channels = input_shape[0]
        n_freqs = self.n_fft // 2 + 1
        return (n_channels * n_channels, n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "CSD"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """Processor function for cross-spectral density estimation operation"""
        logger.debug(f"Applying CSD estimation to array with shape: {x.shape}")
        from scipy import signal as ss

        # Calculate all combinations using scipy's csd function
        _, csd_result = ss.csd(
            x=x[:, np.newaxis],
            y=x[np.newaxis, :],
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
            scaling=self.scaling,
            average=self.average,
        )

        # Reshape result to (n_channels * n_channels, n_freqs)
        result: NDArrayComplex = csd_result.transpose(1, 0, 2).reshape(
            -1, csd_result.shape[-1]
        )

        logger.debug(f"CSD estimation applied, result shape: {result.shape}")
        return result
Attributes
name = 'csd' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
detrend = detrend instance-attribute
scaling = scaling instance-attribute
average = average instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Initialize cross-spectral density estimation operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window function, default is 'hann' detrend : str Type of detrend, default is 'constant' scaling : str Type of scaling, default is 'spectrum' average : str Method of averaging, default is 'mean'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
):
    """
    Initialize cross-spectral density estimation operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window function, default is 'hann'
    detrend : str
        Type of detrend, default is 'constant'
    scaling : str
        Type of scaling, default is 'spectrum'
    average : str
        Method of averaging, default is 'mean'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "CSD"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.detrend = detrend
    self.scaling = scaling
    self.average = average
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=window,
        detrend=detrend,
        scaling=scaling,
        average=average,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels * channels, freqs)

Source code in wandas/processing/spectral.py
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels * channels, freqs)
    """
    n_channels = input_shape[0]
    n_freqs = self.n_fft // 2 + 1
    return (n_channels * n_channels, n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
1014
1015
1016
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "CSD"

FFT

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

FFT (Fast Fourier Transform) operation

Source code in wandas/processing/spectral.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class FFT(AudioOperation[NDArrayReal, NDArrayComplex]):
    """FFT (Fast Fourier Transform) operation"""

    name = "fft"
    n_fft: int | None
    window: str

    def __init__(
        self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
    ):
        """
        Initialize FFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int, optional
            FFT size, default is None (determined by input size)
        window : str, optional
            Window function type, default is 'hann'

        Raises
        ------
        ValueError
            If n_fft is not a positive integer
        """
        # Validate n_fft parameter
        if n_fft is not None and n_fft <= 0:
            raise ValueError(
                f"Invalid FFT size\n"
                f"  Got: {n_fft}\n"
                f"  Expected: Positive integer > 0\n"
                f"FFT size must be a positive integer.\n"
                f"Common values: 512, 1024, 2048, 4096,\n"
                f"8192 (powers of 2 are most efficient)"
            )

        self.n_fft = n_fft
        self.window = window
        super().__init__(sampling_rate, n_fft=n_fft, window=window)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        操作後の出力データの形状を計算します

        Parameters
        ----------
        input_shape : tuple
            入力データの形状 (channels, samples)

        Returns
        -------
        tuple
            出力データの形状 (channels, freqs)
        """
        n_freqs = self.n_fft // 2 + 1 if self.n_fft else input_shape[-1] // 2 + 1
        return (*input_shape[:-1], n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "FFT"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """FFT操作のプロセッサ関数を作成"""
        from scipy.signal import get_window

        if self.n_fft is not None and x.shape[-1] > self.n_fft:
            # If n_fft is specified and input length exceeds it, truncate
            x = x[..., : self.n_fft]

        win = get_window(self.window, x.shape[-1])
        x = x * win
        result: NDArrayComplex = np.fft.rfft(x, n=self.n_fft, axis=-1)
        result[..., 1:-1] *= 2.0
        # 窓関数補正
        scaling_factor = np.sum(win)
        result = result / scaling_factor
        return result
Attributes
name = 'fft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
window = window instance-attribute
Functions
__init__(sampling_rate, n_fft=None, window='hann')

Initialize FFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int, optional FFT size, default is None (determined by input size) window : str, optional Window function type, default is 'hann'

Raises

ValueError If n_fft is not a positive integer

Source code in wandas/processing/spectral.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def __init__(
    self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
):
    """
    Initialize FFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int, optional
        FFT size, default is None (determined by input size)
    window : str, optional
        Window function type, default is 'hann'

    Raises
    ------
    ValueError
        If n_fft is not a positive integer
    """
    # Validate n_fft parameter
    if n_fft is not None and n_fft <= 0:
        raise ValueError(
            f"Invalid FFT size\n"
            f"  Got: {n_fft}\n"
            f"  Expected: Positive integer > 0\n"
            f"FFT size must be a positive integer.\n"
            f"Common values: 512, 1024, 2048, 4096,\n"
            f"8192 (powers of 2 are most efficient)"
        )

    self.n_fft = n_fft
    self.window = window
    super().__init__(sampling_rate, n_fft=n_fft, window=window)
calculate_output_shape(input_shape)

操作後の出力データの形状を計算します

Parameters

input_shape : tuple 入力データの形状 (channels, samples)

Returns

tuple 出力データの形状 (channels, freqs)

Source code in wandas/processing/spectral.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    操作後の出力データの形状を計算します

    Parameters
    ----------
    input_shape : tuple
        入力データの形状 (channels, samples)

    Returns
    -------
    tuple
        出力データの形状 (channels, freqs)
    """
    n_freqs = self.n_fft // 2 + 1 if self.n_fft else input_shape[-1] // 2 + 1
    return (*input_shape[:-1], n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
175
176
177
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "FFT"

IFFT

Bases: AudioOperation[NDArrayComplex, NDArrayReal]

IFFT (Inverse Fast Fourier Transform) operation

Source code in wandas/processing/spectral.py
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
class IFFT(AudioOperation[NDArrayComplex, NDArrayReal]):
    """IFFT (Inverse Fast Fourier Transform) operation"""

    name = "ifft"
    n_fft: int | None
    window: str

    def __init__(
        self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
    ):
        """
        Initialize IFFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : Optional[int], optional
            IFFT size, default is None (determined based on input size)
        window : str, optional
            Window function type, default is 'hann'
        """
        self.n_fft = n_fft
        self.window = window
        super().__init__(sampling_rate, n_fft=n_fft, window=window)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, freqs)

        Returns
        -------
        tuple
            Output data shape (channels, samples)
        """
        n_samples = 2 * (input_shape[-1] - 1) if self.n_fft is None else self.n_fft
        return (*input_shape[:-1], n_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "iFFT"

    def _process_array(self, x: NDArrayComplex) -> NDArrayReal:
        """Create processor function for IFFT operation"""
        logger.debug(f"Applying IFFT to array with shape: {x.shape}")

        # Restore frequency component scaling (remove the 2.0 multiplier applied in FFT)
        _x = x.copy()
        _x[..., 1:-1] /= 2.0

        # Execute IFFT
        result: NDArrayReal = np.fft.irfft(_x, n=self.n_fft, axis=-1)

        # Window function correction (inverse of FFT operation)
        from scipy.signal import get_window

        win = get_window(self.window, result.shape[-1])

        # Correct the FFT window function scaling
        scaling_factor = np.sum(win) / result.shape[-1]
        result = result / scaling_factor

        logger.debug(f"IFFT applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'ifft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
window = window instance-attribute
Functions
__init__(sampling_rate, n_fft=None, window='hann')

Initialize IFFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : Optional[int], optional IFFT size, default is None (determined based on input size) window : str, optional Window function type, default is 'hann'

Source code in wandas/processing/spectral.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def __init__(
    self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
):
    """
    Initialize IFFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : Optional[int], optional
        IFFT size, default is None (determined based on input size)
    window : str, optional
        Window function type, default is 'hann'
    """
    self.n_fft = n_fft
    self.window = window
    super().__init__(sampling_rate, n_fft=n_fft, window=window)
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, freqs)

Returns

tuple Output data shape (channels, samples)

Source code in wandas/processing/spectral.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, freqs)

    Returns
    -------
    tuple
        Output data shape (channels, samples)
    """
    n_samples = 2 * (input_shape[-1] - 1) if self.n_fft is None else self.n_fft
    return (*input_shape[:-1], n_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
240
241
242
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "iFFT"

ISTFT

Bases: AudioOperation[NDArrayComplex, NDArrayReal]

Inverse Short-Time Fourier Transform operation

Source code in wandas/processing/spectral.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
class ISTFT(AudioOperation[NDArrayComplex, NDArrayReal]):
    """Inverse Short-Time Fourier Transform operation"""

    name = "istft"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        length: int | None = None,
    ):
        """
        Initialize ISTFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window type, default is 'hann'
        length : int, optional
            Length of output signal. Default is None (determined from input)

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "ISTFT"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.length = length

        # Instantiate ShortTimeFFT for ISTFT calculation
        self.SFT = ShortTimeFFT(
            win=get_window(window, self.win_length),
            hop=self.hop_length,
            fs=sampling_rate,
            mfft=self.n_fft,
            scale_to="magnitude",  # Consistent scaling with STFT
        )

        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            win_length=self.win_length,
            hop_length=self.hop_length,
            window=window,
            length=length,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after ISTFT operation.

        Uses the SciPy ShortTimeFFT calculation formula to compute the expected
        output length based on the input spectrogram dimensions and output range
        parameters (k0, k1).

        Parameters
        ----------
        input_shape : tuple
            Input spectrogram shape (channels, n_freqs, n_frames)
            where n_freqs = n_fft // 2 + 1 and n_frames is the number of time frames.

        Returns
        -------
        tuple
            Output shape (channels, output_samples) where output_samples is the
            reconstructed signal length determined by the output range [k0, k1).

        Notes
        -----
        The calculation follows SciPy's ShortTimeFFT.istft() implementation.
        When k1 is None (default), the maximum reconstructible signal length is
        computed as:

        .. math::

            q_{max} = n_{frames} + p_{min}

            k_{max} = (q_{max} - 1) \\cdot hop + m_{num} - m_{num\\_mid}

        The output length is then:

        .. math::

            output\\_samples = k_1 - k_0

        where k0 defaults to 0 and k1 defaults to k_max.

        Parameters that affect the calculation:
        - n_frames: number of time frames in the STFT
        - p_min: minimum frame index (ShortTimeFFT property)
        - hop: hop length (samples between frames)
        - m_num: window length
        - m_num_mid: window midpoint position
        - self.length: optional length override (if set, limits output)

        References
        ----------
        - SciPy ShortTimeFFT.istft:
          https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.istft.html
        - SciPy Source: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
        """
        n_channels = input_shape[0]
        n_frames = input_shape[-1]  # time_frames

        # SciPy ShortTimeFFT の計算式に従う
        # See: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
        q_max = n_frames + self.SFT.p_min
        k_max = (q_max - 1) * self.SFT.hop + self.SFT.m_num - self.SFT.m_num_mid

        # Default parameters: k0=0, k1=None (which becomes k_max)
        # The output length is k1 - k0 = k_max - 0 = k_max
        k0 = 0
        k1 = k_max

        # If self.length is specified, it acts as an override to limit the output
        if self.length is not None:
            k1 = min(self.length, k1)

        output_samples = k1 - k0

        return (n_channels, output_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "iSTFT"

    def _process_array(self, x: NDArrayComplex) -> NDArrayReal:
        """
        Apply SciPy ISTFT processing to multiple channels at once using ShortTimeFFT"""
        logger.debug(
            f"Applying SciPy ISTFT (ShortTimeFFT) to array with shape: {x.shape}"
        )

        # Convert 2D input to 3D (assume single channel)
        if x.ndim == 2:
            x = x.reshape(1, *x.shape)

        # Adjust scaling back if STFT applied factor of 2
        _x = np.copy(x)
        _x[..., 1:-1, :] /= 2.0

        # Apply ISTFT using the ShortTimeFFT instance
        result: NDArrayReal = self.SFT.istft(_x)

        # Trim to desired length if specified
        if self.length is not None:
            result = result[..., : self.length]

        logger.debug(
            f"ShortTimeFFT applied, returning result with shape: {result.shape}"
        )
        return result
Attributes
name = 'istft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
length = length instance-attribute
SFT = ShortTimeFFT(win=(get_window(window, self.win_length)), hop=(self.hop_length), fs=sampling_rate, mfft=(self.n_fft), scale_to='magnitude') instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', length=None)

Initialize ISTFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window type, default is 'hann' length : int, optional Length of output signal. Default is None (determined from input)

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    length: int | None = None,
):
    """
    Initialize ISTFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window type, default is 'hann'
    length : int, optional
        Length of output signal. Default is None (determined from input)

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "ISTFT"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.length = length

    # Instantiate ShortTimeFFT for ISTFT calculation
    self.SFT = ShortTimeFFT(
        win=get_window(window, self.win_length),
        hop=self.hop_length,
        fs=sampling_rate,
        mfft=self.n_fft,
        scale_to="magnitude",  # Consistent scaling with STFT
    )

    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        win_length=self.win_length,
        hop_length=self.hop_length,
        window=window,
        length=length,
    )
calculate_output_shape(input_shape)

Calculate output data shape after ISTFT operation.

Uses the SciPy ShortTimeFFT calculation formula to compute the expected output length based on the input spectrogram dimensions and output range parameters (k0, k1).

Parameters

input_shape : tuple Input spectrogram shape (channels, n_freqs, n_frames) where n_freqs = n_fft // 2 + 1 and n_frames is the number of time frames.

Returns

tuple Output shape (channels, output_samples) where output_samples is the reconstructed signal length determined by the output range [k0, k1).

Notes

The calculation follows SciPy's ShortTimeFFT.istft() implementation. When k1 is None (default), the maximum reconstructible signal length is computed as:

.. math::

q_{max} = n_{frames} + p_{min}

k_{max} = (q_{max} - 1) \cdot hop + m_{num} - m_{num\_mid}

The output length is then:

.. math::

output\_samples = k_1 - k_0

where k0 defaults to 0 and k1 defaults to k_max.

Parameters that affect the calculation: - n_frames: number of time frames in the STFT - p_min: minimum frame index (ShortTimeFFT property) - hop: hop length (samples between frames) - m_num: window length - m_num_mid: window midpoint position - self.length: optional length override (if set, limits output)

References
  • SciPy ShortTimeFFT.istft: https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.istft.html
  • SciPy Source: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
Source code in wandas/processing/spectral.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after ISTFT operation.

    Uses the SciPy ShortTimeFFT calculation formula to compute the expected
    output length based on the input spectrogram dimensions and output range
    parameters (k0, k1).

    Parameters
    ----------
    input_shape : tuple
        Input spectrogram shape (channels, n_freqs, n_frames)
        where n_freqs = n_fft // 2 + 1 and n_frames is the number of time frames.

    Returns
    -------
    tuple
        Output shape (channels, output_samples) where output_samples is the
        reconstructed signal length determined by the output range [k0, k1).

    Notes
    -----
    The calculation follows SciPy's ShortTimeFFT.istft() implementation.
    When k1 is None (default), the maximum reconstructible signal length is
    computed as:

    .. math::

        q_{max} = n_{frames} + p_{min}

        k_{max} = (q_{max} - 1) \\cdot hop + m_{num} - m_{num\\_mid}

    The output length is then:

    .. math::

        output\\_samples = k_1 - k_0

    where k0 defaults to 0 and k1 defaults to k_max.

    Parameters that affect the calculation:
    - n_frames: number of time frames in the STFT
    - p_min: minimum frame index (ShortTimeFFT property)
    - hop: hop length (samples between frames)
    - m_num: window length
    - m_num_mid: window midpoint position
    - self.length: optional length override (if set, limits output)

    References
    ----------
    - SciPy ShortTimeFFT.istft:
      https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.istft.html
    - SciPy Source: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
    """
    n_channels = input_shape[0]
    n_frames = input_shape[-1]  # time_frames

    # SciPy ShortTimeFFT の計算式に従う
    # See: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
    q_max = n_frames + self.SFT.p_min
    k_max = (q_max - 1) * self.SFT.hop + self.SFT.m_num - self.SFT.m_num_mid

    # Default parameters: k0=0, k1=None (which becomes k_max)
    # The output length is k1 - k0 = k_max - 0 = k_max
    k0 = 0
    k1 = k_max

    # If self.length is specified, it acts as an override to limit the output
    if self.length is not None:
        k1 = min(self.length, k1)

    output_samples = k1 - k0

    return (n_channels, output_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
509
510
511
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "iSTFT"

STFT

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

Short-Time Fourier Transform operation

Source code in wandas/processing/spectral.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
class STFT(AudioOperation[NDArrayReal, NDArrayComplex]):
    """Short-Time Fourier Transform operation"""

    name = "stft"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
    ):
        """
        Initialize STFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window type, default is 'hann'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "STFT"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.noverlap = (
            self.win_length - self.hop_length if hop_length is not None else None
        )
        self.window = window

        self.SFT = ShortTimeFFT(
            win=get_window(window, self.win_length),
            hop=self.hop_length,
            fs=sampling_rate,
            mfft=self.n_fft,
            scale_to="magnitude",
        )
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            win_length=self.win_length,
            hop_length=self.hop_length,
            window=window,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        n_samples = input_shape[-1]
        n_f = len(self.SFT.f)
        n_t = len(self.SFT.t(n_samples))
        return (input_shape[0], n_f, n_t)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "STFT"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """Apply SciPy STFT processing to multiple channels at once"""
        logger.debug(f"Applying SciPy STFT to array with shape: {x.shape}")

        # Convert 1D input to 2D
        if x.ndim == 1:
            x = x.reshape(1, -1)

        # Apply STFT to all channels at once
        result: NDArrayComplex = self.SFT.stft(x)
        result[..., 1:-1, :] *= 2.0
        logger.debug(f"SciPy STFT applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'stft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
noverlap = self.win_length - self.hop_length if hop_length is not None else None instance-attribute
window = window instance-attribute
SFT = ShortTimeFFT(win=(get_window(window, self.win_length)), hop=(self.hop_length), fs=sampling_rate, mfft=(self.n_fft), scale_to='magnitude') instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann')

Initialize STFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window type, default is 'hann'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
):
    """
    Initialize STFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window type, default is 'hann'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "STFT"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.noverlap = (
        self.win_length - self.hop_length if hop_length is not None else None
    )
    self.window = window

    self.SFT = ShortTimeFFT(
        win=get_window(window, self.win_length),
        hop=self.hop_length,
        fs=sampling_rate,
        mfft=self.n_fft,
        scale_to="magnitude",
    )
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        win_length=self.win_length,
        hop_length=self.hop_length,
        window=window,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/spectral.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    n_samples = input_shape[-1]
    n_f = len(self.SFT.f)
    n_t = len(self.SFT.t(n_samples))
    return (input_shape[0], n_f, n_t)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
349
350
351
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "STFT"

Coherence

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Coherence estimation operation

Source code in wandas/processing/spectral.py
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
class Coherence(AudioOperation[NDArrayReal, NDArrayReal]):
    """Coherence estimation operation"""

    name = "coherence"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
    ):
        """
        Initialize coherence estimation operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window function, default is 'hann'
        detrend : str
            Type of detrend, default is 'constant'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "Coherence"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.detrend = detrend
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=window,
            detrend=detrend,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels * channels, freqs)
        """
        n_channels = input_shape[0]
        n_freqs = self.n_fft // 2 + 1
        return (n_channels * n_channels, n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Coh"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Processor function for coherence estimation operation"""
        logger.debug(f"Applying coherence estimation to array with shape: {x.shape}")
        from scipy import signal as ss

        _, coh = ss.coherence(
            x=x[:, np.newaxis],
            y=x[np.newaxis, :],
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
        )

        # Reshape result to (n_channels * n_channels, n_freqs)
        result: NDArrayReal = coh.transpose(1, 0, 2).reshape(-1, coh.shape[-1])

        logger.debug(f"Coherence estimation applied, result shape: {result.shape}")
        return result
Attributes
name = 'coherence' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
detrend = detrend instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant')

Initialize coherence estimation operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window function, default is 'hann' detrend : str Type of detrend, default is 'constant'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
):
    """
    Initialize coherence estimation operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window function, default is 'hann'
    detrend : str
        Type of detrend, default is 'constant'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "Coherence"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.detrend = detrend
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=window,
        detrend=detrend,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels * channels, freqs)

Source code in wandas/processing/spectral.py
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels * channels, freqs)
    """
    n_channels = input_shape[0]
    n_freqs = self.n_fft // 2 + 1
    return (n_channels * n_channels, n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
903
904
905
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Coh"

NOctSpectrum

Bases: AudioOperation[NDArrayReal, NDArrayReal]

N-octave spectrum operation

Source code in wandas/processing/spectral.py
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
class NOctSpectrum(AudioOperation[NDArrayReal, NDArrayReal]):
    """N-octave spectrum operation"""

    name = "noct_spectrum"

    def __init__(
        self,
        sampling_rate: float,
        fmin: float,
        fmax: float,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ):
        """
        Initialize N-octave spectrum

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        fmin : float
            Minimum frequency (Hz)
        fmax : float
            Maximum frequency (Hz)
        n : int, optional
            Number of octave divisions, default is 3
        G : int, optional
            Reference level, default is 10
        fr : int, optional
            Reference frequency, default is 1000
        """
        super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)
        self.fmin = fmin
        self.fmax = fmax
        self.n = n
        self.G = G
        self.fr = fr

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate output shape for octave spectrum
        _, fpref = _center_freq(
            fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
        )
        return (input_shape[0], fpref.shape[0])

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Oct"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for octave spectrum"""
        logger.debug(f"Applying NoctSpectrum to array with shape: {x.shape}")
        spec, _ = noct_spectrum(
            sig=x.T,
            fs=self.sampling_rate,
            fmin=self.fmin,
            fmax=self.fmax,
            n=self.n,
            G=self.G,
            fr=self.fr,
        )
        if spec.ndim == 1:
            # Add channel dimension for 1D
            spec = np.expand_dims(spec, axis=0)
        else:
            spec = spec.T
        logger.debug(f"NoctSpectrum applied, returning result with shape: {spec.shape}")
        return np.array(spec)
Attributes
name = 'noct_spectrum' class-attribute instance-attribute
fmin = fmin instance-attribute
fmax = fmax instance-attribute
n = n instance-attribute
G = G instance-attribute
fr = fr instance-attribute
Functions
__init__(sampling_rate, fmin, fmax, n=3, G=10, fr=1000)

Initialize N-octave spectrum

Parameters

sampling_rate : float Sampling rate (Hz) fmin : float Minimum frequency (Hz) fmax : float Maximum frequency (Hz) n : int, optional Number of octave divisions, default is 3 G : int, optional Reference level, default is 10 fr : int, optional Reference frequency, default is 1000

Source code in wandas/processing/spectral.py
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
def __init__(
    self,
    sampling_rate: float,
    fmin: float,
    fmax: float,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
):
    """
    Initialize N-octave spectrum

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    fmin : float
        Minimum frequency (Hz)
    fmax : float
        Maximum frequency (Hz)
    n : int, optional
        Number of octave divisions, default is 3
    G : int, optional
        Reference level, default is 10
    fr : int, optional
        Reference frequency, default is 1000
    """
    super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)
    self.fmin = fmin
    self.fmax = fmax
    self.n = n
    self.G = G
    self.fr = fr
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/spectral.py
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate output shape for octave spectrum
    _, fpref = _center_freq(
        fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
    )
    return (input_shape[0], fpref.shape[0])
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
714
715
716
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Oct"

NOctSynthesis

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Octave synthesis operation

Source code in wandas/processing/spectral.py
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
class NOctSynthesis(AudioOperation[NDArrayReal, NDArrayReal]):
    """Octave synthesis operation"""

    name = "noct_synthesis"

    def __init__(
        self,
        sampling_rate: float,
        fmin: float,
        fmax: float,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ):
        """
        Initialize octave synthesis

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        fmin : float
            Minimum frequency (Hz)
        fmax : float
            Maximum frequency (Hz)
        n : int, optional
            Number of octave divisions, default is 3
        G : int, optional
            Reference level, default is 10
        fr : int, optional
            Reference frequency, default is 1000
        """
        super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)

        self.fmin = fmin
        self.fmax = fmax
        self.n = n
        self.G = G
        self.fr = fr

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate output shape for octave spectrum
        _, fpref = _center_freq(
            fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
        )
        return (input_shape[0], fpref.shape[0])

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Octs"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for octave synthesis"""
        logger.debug(f"Applying NoctSynthesis to array with shape: {x.shape}")
        # Calculate n from shape[-1]
        n = x.shape[-1]  # Calculate n from shape[-1]
        if n % 2 == 0:
            n = n * 2 - 1
        else:
            n = (n - 1) * 2
        freqs = np.fft.rfftfreq(n, d=1 / self.sampling_rate)
        result, _ = noct_synthesis(
            spectrum=np.abs(x).T,
            freqs=freqs,
            fmin=self.fmin,
            fmax=self.fmax,
            n=self.n,
            G=self.G,
            fr=self.fr,
        )
        result = result.T
        logger.debug(
            f"NoctSynthesis applied, returning result with shape: {result.shape}"
        )
        return np.array(result)
Attributes
name = 'noct_synthesis' class-attribute instance-attribute
fmin = fmin instance-attribute
fmax = fmax instance-attribute
n = n instance-attribute
G = G instance-attribute
fr = fr instance-attribute
Functions
__init__(sampling_rate, fmin, fmax, n=3, G=10, fr=1000)

Initialize octave synthesis

Parameters

sampling_rate : float Sampling rate (Hz) fmin : float Minimum frequency (Hz) fmax : float Maximum frequency (Hz) n : int, optional Number of octave divisions, default is 3 G : int, optional Reference level, default is 10 fr : int, optional Reference frequency, default is 1000

Source code in wandas/processing/spectral.py
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
def __init__(
    self,
    sampling_rate: float,
    fmin: float,
    fmax: float,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
):
    """
    Initialize octave synthesis

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    fmin : float
        Minimum frequency (Hz)
    fmax : float
        Maximum frequency (Hz)
    n : int, optional
        Number of octave divisions, default is 3
    G : int, optional
        Reference level, default is 10
    fr : int, optional
        Reference frequency, default is 1000
    """
    super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)

    self.fmin = fmin
    self.fmax = fmax
    self.n = n
    self.G = G
    self.fr = fr
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/spectral.py
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate output shape for octave spectrum
    _, fpref = _center_freq(
        fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
    )
    return (input_shape[0], fpref.shape[0])
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
799
800
801
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Octs"

TransferFunction

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

Transfer function estimation operation

Source code in wandas/processing/spectral.py
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
class TransferFunction(AudioOperation[NDArrayReal, NDArrayComplex]):
    """Transfer function estimation operation"""

    name = "transfer_function"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ):
        """
        Initialize transfer function estimation operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window function, default is 'hann'
        detrend : str
            Type of detrend, default is 'constant'
        scaling : str
            Type of scaling, default is 'spectrum'
        average : str
            Method of averaging, default is 'mean'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "Transfer function"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.detrend = detrend
        self.scaling = scaling
        self.average = average
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels * channels, freqs)
        """
        n_channels = input_shape[0]
        n_freqs = self.n_fft // 2 + 1
        return (n_channels * n_channels, n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "H"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """Processor function for transfer function estimation operation"""
        logger.debug(
            f"Applying transfer function estimation to array with shape: {x.shape}"
        )
        from scipy import signal as ss

        # Calculate cross-spectral density between all channels
        f, p_yx = ss.csd(
            x=x[:, np.newaxis, :],
            y=x[np.newaxis, :, :],
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
            scaling=self.scaling,
            average=self.average,
            axis=-1,
        )
        # p_yx shape: (num_channels, num_channels, num_frequencies)

        # Calculate power spectral density for each channel
        f, p_xx = ss.welch(
            x=x,
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
            scaling=self.scaling,
            average=self.average,
            axis=-1,
        )
        # p_xx shape: (num_channels, num_frequencies)

        # Calculate transfer function H(f) = P_yx / P_xx
        h_f = p_yx / p_xx[np.newaxis, :, :]
        result: NDArrayComplex = h_f.transpose(1, 0, 2).reshape(-1, h_f.shape[-1])

        logger.debug(
            f"Transfer function estimation applied, result shape: {result.shape}"
        )
        return result
Attributes
name = 'transfer_function' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
detrend = detrend instance-attribute
scaling = scaling instance-attribute
average = average instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Initialize transfer function estimation operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window function, default is 'hann' detrend : str Type of detrend, default is 'constant' scaling : str Type of scaling, default is 'spectrum' average : str Method of averaging, default is 'mean'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
):
    """
    Initialize transfer function estimation operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window function, default is 'hann'
    detrend : str
        Type of detrend, default is 'constant'
    scaling : str
        Type of scaling, default is 'spectrum'
    average : str
        Method of averaging, default is 'mean'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "Transfer function"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.detrend = detrend
    self.scaling = scaling
    self.average = average
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=window,
        detrend=detrend,
        scaling=scaling,
        average=average,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels * channels, freqs)

Source code in wandas/processing/spectral.py
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels * channels, freqs)
    """
    n_channels = input_shape[0]
    n_freqs = self.n_fft // 2 + 1
    return (n_channels * n_channels, n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
1130
1131
1132
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "H"

Welch

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Welch

Source code in wandas/processing/spectral.py
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
class Welch(AudioOperation[NDArrayReal, NDArrayReal]):
    """Welch"""

    name = "welch"
    n_fft: int
    window: str
    hop_length: int | None
    win_length: int | None
    average: str
    detrend: str

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        average: str = "mean",
        detrend: str = "constant",
    ):
        """
        Initialize Welch operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int, optional
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str, optional
            Window function type, default is 'hann'
        average : str, optional
            Averaging method, default is 'mean'
        detrend : str, optional
            Detrend method, default is 'constant'

        Raises
        ------
        ValueError
            If n_fft, win_length, or hop_length are invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "Welch method"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.noverlap = (
            self.win_length - self.hop_length if hop_length is not None else None
        )
        self.window = window
        self.average = average
        self.detrend = detrend
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            win_length=self.win_length,
            hop_length=self.hop_length,
            window=window,
            average=average,
            detrend=detrend,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels, freqs)
        """
        n_freqs = self.n_fft // 2 + 1
        return (*input_shape[:-1], n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "PS"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for Welch operation"""
        from scipy import signal as ss

        _, result = ss.welch(
            x,
            nperseg=self.win_length,
            noverlap=self.noverlap,
            nfft=self.n_fft,
            window=self.window,
            average=self.average,
            detrend=self.detrend,
            scaling="spectrum",
        )

        if not isinstance(x, np.ndarray):
            # Trigger computation for Dask array
            raise ValueError(
                "Welch operation requires a Dask array, but received a non-ndarray."
            )
        return np.array(result)
Attributes
name = 'welch' class-attribute instance-attribute
n_fft = n_fft instance-attribute
window = window instance-attribute
hop_length = actual_hop_length instance-attribute
win_length = actual_win_length instance-attribute
average = average instance-attribute
detrend = detrend instance-attribute
noverlap = self.win_length - self.hop_length if hop_length is not None else None instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', average='mean', detrend='constant')

Initialize Welch operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int, optional FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str, optional Window function type, default is 'hann' average : str, optional Averaging method, default is 'mean' detrend : str, optional Detrend method, default is 'constant'

Raises

ValueError If n_fft, win_length, or hop_length are invalid

Source code in wandas/processing/spectral.py
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    average: str = "mean",
    detrend: str = "constant",
):
    """
    Initialize Welch operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int, optional
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str, optional
        Window function type, default is 'hann'
    average : str, optional
        Averaging method, default is 'mean'
    detrend : str, optional
        Detrend method, default is 'constant'

    Raises
    ------
    ValueError
        If n_fft, win_length, or hop_length are invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "Welch method"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.noverlap = (
        self.win_length - self.hop_length if hop_length is not None else None
    )
    self.window = window
    self.average = average
    self.detrend = detrend
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        win_length=self.win_length,
        hop_length=self.hop_length,
        window=window,
        average=average,
        detrend=detrend,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels, freqs)

Source code in wandas/processing/spectral.py
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels, freqs)
    """
    n_freqs = self.n_fft // 2 + 1
    return (*input_shape[:-1], n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
628
629
630
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "PS"

ABS

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Absolute value operation

Source code in wandas/processing/stats.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ABS(AudioOperation[NDArrayReal, NDArrayReal]):
    """Absolute value operation"""

    name = "abs"

    def __init__(self, sampling_rate: float):
        """
        Initialize absolute value operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        super().__init__(sampling_rate)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "abs"

    def process(self, data: DaArray) -> DaArray:
        # map_blocksを使わず、直接Daskの集約関数を使用
        return da.abs(data)  # type: ignore [unused-ignore]
Attributes
name = 'abs' class-attribute instance-attribute
Functions
__init__(sampling_rate)

Initialize absolute value operation

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/stats.py
17
18
19
20
21
22
23
24
25
26
def __init__(self, sampling_rate: float):
    """
    Initialize absolute value operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    super().__init__(sampling_rate)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
28
29
30
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "abs"
process(data)
Source code in wandas/processing/stats.py
32
33
34
def process(self, data: DaArray) -> DaArray:
    # map_blocksを使わず、直接Daskの集約関数を使用
    return da.abs(data)  # type: ignore [unused-ignore]

ChannelDifference

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Channel difference calculation operation

Source code in wandas/processing/stats.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class ChannelDifference(AudioOperation[NDArrayReal, NDArrayReal]):
    """Channel difference calculation operation"""

    name = "channel_difference"
    other_channel: int

    def __init__(self, sampling_rate: float, other_channel: int = 0):
        """
        Initialize channel difference calculation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        other_channel : int
            Channel to calculate difference with, default is 0
        """
        self.other_channel = other_channel
        super().__init__(sampling_rate, other_channel=other_channel)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "diff"

    def process(self, data: DaArray) -> DaArray:
        # map_blocksを使わず、直接Daskの集約関数を使用
        result = data - data[self.other_channel]
        return result
Attributes
name = 'channel_difference' class-attribute instance-attribute
other_channel = other_channel instance-attribute
Functions
__init__(sampling_rate, other_channel=0)

Initialize channel difference calculation

Parameters

sampling_rate : float Sampling rate (Hz) other_channel : int Channel to calculate difference with, default is 0

Source code in wandas/processing/stats.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(self, sampling_rate: float, other_channel: int = 0):
    """
    Initialize channel difference calculation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    other_channel : int
        Channel to calculate difference with, default is 0
    """
    self.other_channel = other_channel
    super().__init__(sampling_rate, other_channel=other_channel)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
113
114
115
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "diff"
process(data)
Source code in wandas/processing/stats.py
117
118
119
120
def process(self, data: DaArray) -> DaArray:
    # map_blocksを使わず、直接Daskの集約関数を使用
    result = data - data[self.other_channel]
    return result

Mean

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Mean calculation

Source code in wandas/processing/stats.py
79
80
81
82
83
84
85
86
87
88
89
90
class Mean(AudioOperation[NDArrayReal, NDArrayReal]):
    """Mean calculation"""

    name = "mean"

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "mean"

    def process(self, data: DaArray) -> DaArray:
        # Use Dask's aggregate function directly without map_blocks
        return data.mean(axis=0, keepdims=True)
Attributes
name = 'mean' class-attribute instance-attribute
Functions
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
84
85
86
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "mean"
process(data)
Source code in wandas/processing/stats.py
88
89
90
def process(self, data: DaArray) -> DaArray:
    # Use Dask's aggregate function directly without map_blocks
    return data.mean(axis=0, keepdims=True)

Power

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Power operation

Source code in wandas/processing/stats.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Power(AudioOperation[NDArrayReal, NDArrayReal]):
    """Power operation"""

    name = "power"

    def __init__(self, sampling_rate: float, exponent: float):
        """
        Initialize power operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        exponent : float
            Power exponent
        """
        super().__init__(sampling_rate)
        self.exp = exponent

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "pow"

    def process(self, data: DaArray) -> DaArray:
        # map_blocksを使わず、直接Daskの集約関数を使用
        return da.power(data, self.exp)  # type: ignore [unused-ignore]
Attributes
name = 'power' class-attribute instance-attribute
exp = exponent instance-attribute
Functions
__init__(sampling_rate, exponent)

Initialize power operation

Parameters

sampling_rate : float Sampling rate (Hz) exponent : float Power exponent

Source code in wandas/processing/stats.py
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(self, sampling_rate: float, exponent: float):
    """
    Initialize power operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    exponent : float
        Power exponent
    """
    super().__init__(sampling_rate)
    self.exp = exponent
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
56
57
58
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "pow"
process(data)
Source code in wandas/processing/stats.py
60
61
62
def process(self, data: DaArray) -> DaArray:
    # map_blocksを使わず、直接Daskの集約関数を使用
    return da.power(data, self.exp)  # type: ignore [unused-ignore]

Sum

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Sum calculation

Source code in wandas/processing/stats.py
65
66
67
68
69
70
71
72
73
74
75
76
class Sum(AudioOperation[NDArrayReal, NDArrayReal]):
    """Sum calculation"""

    name = "sum"

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "sum"

    def process(self, data: DaArray) -> DaArray:
        # Use Dask's aggregate function directly without map_blocks
        return data.sum(axis=0, keepdims=True)
Attributes
name = 'sum' class-attribute instance-attribute
Functions
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
70
71
72
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "sum"
process(data)
Source code in wandas/processing/stats.py
74
75
76
def process(self, data: DaArray) -> DaArray:
    # Use Dask's aggregate function directly without map_blocks
    return data.sum(axis=0, keepdims=True)

ReSampling

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Resampling operation

Source code in wandas/processing/temporal.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class ReSampling(AudioOperation[NDArrayReal, NDArrayReal]):
    """Resampling operation"""

    name = "resampling"

    def __init__(self, sampling_rate: float, target_sr: float):
        """
        Initialize resampling operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        target_sampling_rate : float
            Target sampling rate (Hz)

        Raises
        ------
        ValueError
            If sampling_rate or target_sr is not positive
        """
        validate_sampling_rate(sampling_rate, "source sampling rate")
        validate_sampling_rate(target_sr, "target sampling rate")
        super().__init__(sampling_rate, target_sr=target_sr)
        self.target_sr = target_sr

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Update sampling rate to target sampling rate.

        Returns
        -------
        dict
            Metadata updates with new sampling rate

        Notes
        -----
        Resampling always produces output at target_sr, regardless of input
        sampling rate. All necessary parameters are provided at initialization.
        """
        return {"sampling_rate": self.target_sr}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate length after resampling
        ratio = float(self.target_sr) / float(self.sampling_rate)
        n_samples = int(np.ceil(input_shape[-1] * ratio))
        return (*input_shape[:-1], n_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "rs"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for resampling operation"""
        logger.debug(f"Applying resampling to array with shape: {x.shape}")
        result: NDArrayReal = librosa.resample(
            x, orig_sr=self.sampling_rate, target_sr=self.target_sr
        )
        logger.debug(f"Resampling applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'resampling' class-attribute instance-attribute
target_sr = target_sr instance-attribute
Functions
__init__(sampling_rate, target_sr)

Initialize resampling operation

Parameters

sampling_rate : float Sampling rate (Hz) target_sampling_rate : float Target sampling rate (Hz)

Raises

ValueError If sampling_rate or target_sr is not positive

Source code in wandas/processing/temporal.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def __init__(self, sampling_rate: float, target_sr: float):
    """
    Initialize resampling operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    target_sampling_rate : float
        Target sampling rate (Hz)

    Raises
    ------
    ValueError
        If sampling_rate or target_sr is not positive
    """
    validate_sampling_rate(sampling_rate, "source sampling rate")
    validate_sampling_rate(target_sr, "target sampling rate")
    super().__init__(sampling_rate, target_sr=target_sr)
    self.target_sr = target_sr
get_metadata_updates()

Update sampling rate to target sampling rate.

Returns

dict Metadata updates with new sampling rate

Notes

Resampling always produces output at target_sr, regardless of input sampling rate. All necessary parameters are provided at initialization.

Source code in wandas/processing/temporal.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Update sampling rate to target sampling rate.

    Returns
    -------
    dict
        Metadata updates with new sampling rate

    Notes
    -----
    Resampling always produces output at target_sr, regardless of input
    sampling rate. All necessary parameters are provided at initialization.
    """
    return {"sampling_rate": self.target_sr}
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/temporal.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate length after resampling
    ratio = float(self.target_sr) / float(self.sampling_rate)
    n_samples = int(np.ceil(input_shape[-1] * ratio))
    return (*input_shape[:-1], n_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/temporal.py
76
77
78
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "rs"

RmsTrend

Bases: AudioOperation[NDArrayReal, NDArrayReal]

RMS calculation

Source code in wandas/processing/temporal.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
class RmsTrend(AudioOperation[NDArrayReal, NDArrayReal]):
    """RMS calculation"""

    name = "rms_trend"
    frame_length: int
    hop_length: int
    Aw: bool

    def __init__(
        self,
        sampling_rate: float,
        frame_length: int = 2048,
        hop_length: int = 512,
        ref: list[float] | float = 1.0,
        dB: bool = False,  # noqa: N803
        Aw: bool = False,  # noqa: N803
    ) -> None:
        """
        Initialize RMS calculation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        frame_length : int
            Frame length, default is 2048
        hop_length : int
            Hop length, default is 512
        ref : Union[list[float], float]
            Reference value(s) for dB calculation
        dB : bool
            Whether to convert to decibels
        Aw : bool
            Whether to apply A-weighting before RMS calculation
        """
        self.frame_length = frame_length
        self.hop_length = hop_length
        self.dB = dB
        self.Aw = Aw
        self.ref = np.array(ref if isinstance(ref, list) else [ref])
        super().__init__(
            sampling_rate,
            frame_length=frame_length,
            hop_length=hop_length,
            dB=dB,
            Aw=Aw,
            ref=self.ref,
        )

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Update sampling rate based on hop length.

        Returns
        -------
        dict
            Metadata updates with new sampling rate based on hop length

        Notes
        -----
        The output sampling rate is determined by downsampling the input
        by hop_length. All necessary parameters are provided at initialization.
        """
        new_sr = self.sampling_rate / self.hop_length
        return {"sampling_rate": new_sr}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels, frames)
        """
        n_frames = librosa.feature.rms(
            y=np.ones((1, input_shape[-1])),
            frame_length=self.frame_length,
            hop_length=self.hop_length,
        ).shape[-1]
        return (*input_shape[:-1], n_frames)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "RMS"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for RMS calculation"""
        logger.debug(f"Applying RMS to array with shape: {x.shape}")

        if self.Aw:
            # Apply A-weighting
            _x = A_weight(x, self.sampling_rate)
            if isinstance(_x, np.ndarray):
                # A_weightがタプルを返す場合、最初の要素を使用
                x = _x
            elif isinstance(_x, tuple):
                # Use the first element if A_weight returns a tuple
                x = _x[0]
            else:
                raise ValueError("A_weighting returned an unexpected type.")

        # Calculate RMS
        result: NDArrayReal = librosa.feature.rms(
            y=x, frame_length=self.frame_length, hop_length=self.hop_length
        )[..., 0, :]

        if self.dB:
            # Convert to dB
            result = 20 * np.log10(
                np.maximum(result / self.ref[..., np.newaxis], 1e-12)
            )
        #
        logger.debug(f"RMS applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'rms_trend' class-attribute instance-attribute
frame_length = frame_length instance-attribute
hop_length = hop_length instance-attribute
Aw = Aw instance-attribute
dB = dB instance-attribute
ref = np.array(ref if isinstance(ref, list) else [ref]) instance-attribute
Functions
__init__(sampling_rate, frame_length=2048, hop_length=512, ref=1.0, dB=False, Aw=False)

Initialize RMS calculation

Parameters

sampling_rate : float Sampling rate (Hz) frame_length : int Frame length, default is 2048 hop_length : int Hop length, default is 512 ref : Union[list[float], float] Reference value(s) for dB calculation dB : bool Whether to convert to decibels Aw : bool Whether to apply A-weighting before RMS calculation

Source code in wandas/processing/temporal.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def __init__(
    self,
    sampling_rate: float,
    frame_length: int = 2048,
    hop_length: int = 512,
    ref: list[float] | float = 1.0,
    dB: bool = False,  # noqa: N803
    Aw: bool = False,  # noqa: N803
) -> None:
    """
    Initialize RMS calculation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    frame_length : int
        Frame length, default is 2048
    hop_length : int
        Hop length, default is 512
    ref : Union[list[float], float]
        Reference value(s) for dB calculation
    dB : bool
        Whether to convert to decibels
    Aw : bool
        Whether to apply A-weighting before RMS calculation
    """
    self.frame_length = frame_length
    self.hop_length = hop_length
    self.dB = dB
    self.Aw = Aw
    self.ref = np.array(ref if isinstance(ref, list) else [ref])
    super().__init__(
        sampling_rate,
        frame_length=frame_length,
        hop_length=hop_length,
        dB=dB,
        Aw=Aw,
        ref=self.ref,
    )
get_metadata_updates()

Update sampling rate based on hop length.

Returns

dict Metadata updates with new sampling rate based on hop length

Notes

The output sampling rate is determined by downsampling the input by hop_length. All necessary parameters are provided at initialization.

Source code in wandas/processing/temporal.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Update sampling rate based on hop length.

    Returns
    -------
    dict
        Metadata updates with new sampling rate based on hop length

    Notes
    -----
    The output sampling rate is determined by downsampling the input
    by hop_length. All necessary parameters are provided at initialization.
    """
    new_sr = self.sampling_rate / self.hop_length
    return {"sampling_rate": new_sr}
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels, frames)

Source code in wandas/processing/temporal.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels, frames)
    """
    n_frames = librosa.feature.rms(
        y=np.ones((1, input_shape[-1])),
        frame_length=self.frame_length,
        hop_length=self.hop_length,
    ).shape[-1]
    return (*input_shape[:-1], n_frames)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/temporal.py
307
308
309
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "RMS"

Trim

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Trimming operation

Source code in wandas/processing/temporal.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
class Trim(AudioOperation[NDArrayReal, NDArrayReal]):
    """Trimming operation"""

    name = "trim"

    def __init__(
        self,
        sampling_rate: float,
        start: float,
        end: float,
    ):
        """
        Initialize trimming operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        start : float
            Start time for trimming (seconds)
        end : float
            End time for trimming (seconds)
        """
        super().__init__(sampling_rate, start=start, end=end)
        self.start = start
        self.end = end
        self.start_sample = int(start * sampling_rate)
        self.end_sample = int(end * sampling_rate)
        logger.debug(
            f"Initialized Trim operation with start: {self.start}, end: {self.end}"
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate length after trimming
        # Exclude parts where there is no signal
        end_sample = min(self.end_sample, input_shape[-1])
        n_samples = end_sample - self.start_sample
        return (*input_shape[:-1], n_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "trim"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for trimming operation"""
        logger.debug(f"Applying trim to array with shape: {x.shape}")
        # Apply trimming
        result = x[..., self.start_sample : self.end_sample]
        logger.debug(f"Trim applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'trim' class-attribute instance-attribute
start = start instance-attribute
end = end instance-attribute
start_sample = int(start * sampling_rate) instance-attribute
end_sample = int(end * sampling_rate) instance-attribute
Functions
__init__(sampling_rate, start, end)

Initialize trimming operation

Parameters

sampling_rate : float Sampling rate (Hz) start : float Start time for trimming (seconds) end : float End time for trimming (seconds)

Source code in wandas/processing/temporal.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def __init__(
    self,
    sampling_rate: float,
    start: float,
    end: float,
):
    """
    Initialize trimming operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    start : float
        Start time for trimming (seconds)
    end : float
        End time for trimming (seconds)
    """
    super().__init__(sampling_rate, start=start, end=end)
    self.start = start
    self.end = end
    self.start_sample = int(start * sampling_rate)
    self.end_sample = int(end * sampling_rate)
    logger.debug(
        f"Initialized Trim operation with start: {self.start}, end: {self.end}"
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/temporal.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate length after trimming
    # Exclude parts where there is no signal
    end_sample = min(self.end_sample, input_shape[-1])
    n_samples = end_sample - self.start_sample
    return (*input_shape[:-1], n_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/temporal.py
142
143
144
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "trim"

Functions

create_operation(name, sampling_rate, **params)

Create operation instance from name and parameters

Source code in wandas/processing/base.py
286
287
288
289
290
291
def create_operation(
    name: str, sampling_rate: float, **params: Any
) -> AudioOperation[Any, Any]:
    """Create operation instance from name and parameters"""
    operation_class = get_operation(name)
    return operation_class(sampling_rate, **params)

get_operation(name)

Get operation class by name

Source code in wandas/processing/base.py
279
280
281
282
283
def get_operation(name: str) -> type[AudioOperation[Any, Any]]:
    """Get operation class by name"""
    if name not in _OPERATION_REGISTRY:
        raise ValueError(f"Unknown operation type: {name}")
    return _OPERATION_REGISTRY[name]

register_operation(operation_class)

Register a new operation type

Source code in wandas/processing/base.py
268
269
270
271
272
273
274
275
276
def register_operation(operation_class: type) -> None:
    """Register a new operation type"""

    if not issubclass(operation_class, AudioOperation):
        raise TypeError("Strategy class must inherit from AudioOperation.")
    if inspect.isabstract(operation_class):
        raise TypeError("Cannot register abstract AudioOperation class.")

    _OPERATION_REGISTRY[operation_class.name] = operation_class

Modules

base

Attributes
logger = logging.getLogger(__name__) module-attribute
InputArrayType = TypeVar('InputArrayType', NDArrayReal, NDArrayComplex) module-attribute
OutputArrayType = TypeVar('OutputArrayType', NDArrayReal, NDArrayComplex) module-attribute
Classes
AudioOperation

Bases: Generic[InputArrayType, OutputArrayType]

Abstract base class for audio processing operations.

Source code in wandas/processing/base.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
class AudioOperation(Generic[InputArrayType, OutputArrayType]):
    """Abstract base class for audio processing operations."""

    # Class variable: operation name
    name: ClassVar[str]

    def __init__(self, sampling_rate: float, *, pure: bool = True, **params: Any):
        """
        Initialize AudioOperation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        pure : bool, default=True
            Whether the operation is pure (deterministic with no side effects).
            When True, Dask can cache results for identical inputs.
            Set to False only if the operation has side effects or is non-deterministic.
        **params : Any
            Operation-specific parameters
        """
        self.sampling_rate = sampling_rate
        self.pure = pure
        self.params = params

        # Validate parameters during initialization
        self.validate_params()

        # Create processor function (lazy initialization possible)
        self._setup_processor()

        logger.debug(
            f"Initialized {self.__class__.__name__} operation with params: {params}"
        )

    def validate_params(self) -> None:
        """Validate parameters (raises exception if invalid)"""
        pass

    def _setup_processor(self) -> None:
        """Set up processor function (implemented by subclasses)"""
        pass

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Get metadata updates to apply after processing.

        This method allows operations to specify how metadata should be
        updated after processing. By default, no metadata is updated.

        Returns
        -------
        dict
            Dictionary of metadata updates. Can include:
            - 'sampling_rate': New sampling rate (float)
            - Other metadata keys as needed

        Examples
        --------
        Return empty dict for operations that don't change metadata:

        >>> return {}

        Return new sampling rate for operations that resample:

        >>> return {"sampling_rate": self.target_sr}

        Notes
        -----
        This method is called by the framework after processing to update
        the frame metadata. Subclasses should override this method if they
        need to update metadata (e.g., changing sampling rate).

        Design principle: Operations should use parameters provided at
        initialization (via __init__). All necessary information should be
        available as instance variables.
        """
        return {}

    def get_display_name(self) -> str | None:
        """
        Get display name for the operation for use in channel labels.

        This method allows operations to customize how they appear in
        channel labels. By default, returns None, which means the
        operation name will be used.

        Returns
        -------
        str or None
            Display name for the operation. If None, the operation name
            (from the `name` class variable) is used.

        Examples
        --------
        Default behavior (returns None, uses operation name):

        >>> class NormalizeOp(AudioOperation):
        ...     name = "normalize"
        >>> op = NormalizeOp(44100)
        >>> op.get_display_name()  # Returns None
        >>> # Channel label: "normalize(ch0)"

        Custom display name:

        >>> class LowPassFilter(AudioOperation):
        ...     name = "lowpass_filter"
        ...
        ...     def __init__(self, sr, cutoff):
        ...         self.cutoff = cutoff
        ...         super().__init__(sr, cutoff=cutoff)
        ...
        ...     def get_display_name(self):
        ...         return f"lpf_{self.cutoff}Hz"
        >>> op = LowPassFilter(44100, cutoff=1000)
        >>> op.get_display_name()  # Returns "lpf_1000Hz"
        >>> # Channel label: "lpf_1000Hz(ch0)"

        Notes
        -----
        Subclasses can override this method to provide operation-specific
        display names that include parameter information, making labels
        more informative.
        """
        return None

    def _process_array(self, x: InputArrayType) -> OutputArrayType:
        """Processing function (implemented by subclasses)"""
        # Default is no-op function
        raise NotImplementedError("Subclasses must implement this method.")

    def _create_named_wrapper(self) -> Any:
        """
        Create a named wrapper function for better Dask graph visualization.

        Returns
        -------
        callable
            A wrapper function with the operation name set as __name__.
        """

        def operation_wrapper(x: InputArrayType) -> OutputArrayType:
            return self._process_array(x)

        # Set the function name to the operation name for better visualization
        operation_wrapper.__name__ = self.name
        return operation_wrapper

    def process_array(self, x: InputArrayType) -> Any:
        """
        Processing function wrapped with @dask.delayed.

        This method returns a Delayed object that can be computed later.
        The operation name is used in the Dask task graph for better visualization.

        Parameters
        ----------
        x : InputArrayType
            Input array to process.

        Returns
        -------
        dask.delayed.Delayed
            A Delayed object representing the computation.
        """
        logger.debug(f"Creating delayed operation on data with shape: {x.shape}")
        # Create wrapper with operation name and wrap it with dask.delayed
        wrapper = self._create_named_wrapper()
        delayed_func = delayed(wrapper, pure=self.pure)
        return delayed_func(x)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation.

        This method can be overridden by subclasses for efficiency.
        If not overridden, it will execute _process_array on a small test array
        to determine the output shape.

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape

        Notes
        -----
        The default implementation creates a minimal test array and processes it
        to determine output shape. For performance-critical code, subclasses should
        override this method with a direct calculation.
        """
        # Try to infer shape by executing _process_array on test data
        import numpy as np

        try:
            # Create minimal test array with input shape
            if len(input_shape) == 0:
                return input_shape

            # Create test input with correct dtype
            # Try complex first, fall back to float if needed
            test_input: Any = np.zeros(input_shape, dtype=np.complex128)

            # Process test input
            test_output: Any = self._process_array(test_input)

            # Return the shape of the output
            if isinstance(test_output, np.ndarray):
                return tuple(int(s) for s in test_output.shape)
            return input_shape
        except Exception as e:
            logger.warning(
                f"Failed to infer output shape for {self.__class__.__name__}: {e}. "
                "Please implement calculate_output_shape method."
            )
            raise NotImplementedError(
                f"Subclass {self.__class__.__name__} must implement "
                f"calculate_output_shape or ensure _process_array can be "
                f"called with test data."
            ) from e

    def process(self, data: DaArray) -> DaArray:
        """
        Execute operation and return result
        data shape is (channels, samples)
        """
        # Add task as delayed processing with custom name for visualization
        logger.debug("Adding delayed operation to computation graph")

        # Create a wrapper function with the operation name
        # This allows Dask to use the operation name in the task graph
        wrapper = self._create_named_wrapper()
        delayed_func = delayed(wrapper, pure=self.pure)
        delayed_result = delayed_func(data)

        # Convert delayed result to dask array and return
        output_shape = self.calculate_output_shape(data.shape)
        return _da_from_delayed(delayed_result, shape=output_shape, dtype=data.dtype)
Attributes
name class-attribute
sampling_rate = sampling_rate instance-attribute
pure = pure instance-attribute
params = params instance-attribute
Functions
__init__(sampling_rate, *, pure=True, **params)

Initialize AudioOperation.

Parameters

sampling_rate : float Sampling rate (Hz) pure : bool, default=True Whether the operation is pure (deterministic with no side effects). When True, Dask can cache results for identical inputs. Set to False only if the operation has side effects or is non-deterministic. **params : Any Operation-specific parameters

Source code in wandas/processing/base.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(self, sampling_rate: float, *, pure: bool = True, **params: Any):
    """
    Initialize AudioOperation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    pure : bool, default=True
        Whether the operation is pure (deterministic with no side effects).
        When True, Dask can cache results for identical inputs.
        Set to False only if the operation has side effects or is non-deterministic.
    **params : Any
        Operation-specific parameters
    """
    self.sampling_rate = sampling_rate
    self.pure = pure
    self.params = params

    # Validate parameters during initialization
    self.validate_params()

    # Create processor function (lazy initialization possible)
    self._setup_processor()

    logger.debug(
        f"Initialized {self.__class__.__name__} operation with params: {params}"
    )
validate_params()

Validate parameters (raises exception if invalid)

Source code in wandas/processing/base.py
55
56
57
def validate_params(self) -> None:
    """Validate parameters (raises exception if invalid)"""
    pass
get_metadata_updates()

Get metadata updates to apply after processing.

This method allows operations to specify how metadata should be updated after processing. By default, no metadata is updated.

Returns

dict Dictionary of metadata updates. Can include: - 'sampling_rate': New sampling rate (float) - Other metadata keys as needed

Examples

Return empty dict for operations that don't change metadata:

return {}

Return new sampling rate for operations that resample:

return {"sampling_rate": self.target_sr}

Notes

This method is called by the framework after processing to update the frame metadata. Subclasses should override this method if they need to update metadata (e.g., changing sampling rate).

Design principle: Operations should use parameters provided at initialization (via init). All necessary information should be available as instance variables.

Source code in wandas/processing/base.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Get metadata updates to apply after processing.

    This method allows operations to specify how metadata should be
    updated after processing. By default, no metadata is updated.

    Returns
    -------
    dict
        Dictionary of metadata updates. Can include:
        - 'sampling_rate': New sampling rate (float)
        - Other metadata keys as needed

    Examples
    --------
    Return empty dict for operations that don't change metadata:

    >>> return {}

    Return new sampling rate for operations that resample:

    >>> return {"sampling_rate": self.target_sr}

    Notes
    -----
    This method is called by the framework after processing to update
    the frame metadata. Subclasses should override this method if they
    need to update metadata (e.g., changing sampling rate).

    Design principle: Operations should use parameters provided at
    initialization (via __init__). All necessary information should be
    available as instance variables.
    """
    return {}
get_display_name()

Get display name for the operation for use in channel labels.

This method allows operations to customize how they appear in channel labels. By default, returns None, which means the operation name will be used.

Returns

str or None Display name for the operation. If None, the operation name (from the name class variable) is used.

Examples

Default behavior (returns None, uses operation name):

class NormalizeOp(AudioOperation): ... name = "normalize" op = NormalizeOp(44100) op.get_display_name() # Returns None

Channel label: "normalize(ch0)"

Custom display name:

class LowPassFilter(AudioOperation): ... name = "lowpass_filter" ... ... def init(self, sr, cutoff): ... self.cutoff = cutoff ... super().init(sr, cutoff=cutoff) ... ... def get_display_name(self): ... return f"lpf_{self.cutoff}Hz" op = LowPassFilter(44100, cutoff=1000) op.get_display_name() # Returns "lpf_1000Hz"

Channel label: "lpf_1000Hz(ch0)"
Notes

Subclasses can override this method to provide operation-specific display names that include parameter information, making labels more informative.

Source code in wandas/processing/base.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def get_display_name(self) -> str | None:
    """
    Get display name for the operation for use in channel labels.

    This method allows operations to customize how they appear in
    channel labels. By default, returns None, which means the
    operation name will be used.

    Returns
    -------
    str or None
        Display name for the operation. If None, the operation name
        (from the `name` class variable) is used.

    Examples
    --------
    Default behavior (returns None, uses operation name):

    >>> class NormalizeOp(AudioOperation):
    ...     name = "normalize"
    >>> op = NormalizeOp(44100)
    >>> op.get_display_name()  # Returns None
    >>> # Channel label: "normalize(ch0)"

    Custom display name:

    >>> class LowPassFilter(AudioOperation):
    ...     name = "lowpass_filter"
    ...
    ...     def __init__(self, sr, cutoff):
    ...         self.cutoff = cutoff
    ...         super().__init__(sr, cutoff=cutoff)
    ...
    ...     def get_display_name(self):
    ...         return f"lpf_{self.cutoff}Hz"
    >>> op = LowPassFilter(44100, cutoff=1000)
    >>> op.get_display_name()  # Returns "lpf_1000Hz"
    >>> # Channel label: "lpf_1000Hz(ch0)"

    Notes
    -----
    Subclasses can override this method to provide operation-specific
    display names that include parameter information, making labels
    more informative.
    """
    return None
process_array(x)

Processing function wrapped with @dask.delayed.

This method returns a Delayed object that can be computed later. The operation name is used in the Dask task graph for better visualization.

Parameters

x : InputArrayType Input array to process.

Returns

dask.delayed.Delayed A Delayed object representing the computation.

Source code in wandas/processing/base.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def process_array(self, x: InputArrayType) -> Any:
    """
    Processing function wrapped with @dask.delayed.

    This method returns a Delayed object that can be computed later.
    The operation name is used in the Dask task graph for better visualization.

    Parameters
    ----------
    x : InputArrayType
        Input array to process.

    Returns
    -------
    dask.delayed.Delayed
        A Delayed object representing the computation.
    """
    logger.debug(f"Creating delayed operation on data with shape: {x.shape}")
    # Create wrapper with operation name and wrap it with dask.delayed
    wrapper = self._create_named_wrapper()
    delayed_func = delayed(wrapper, pure=self.pure)
    return delayed_func(x)
calculate_output_shape(input_shape)

Calculate output data shape after operation.

This method can be overridden by subclasses for efficiency. If not overridden, it will execute _process_array on a small test array to determine the output shape.

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Notes

The default implementation creates a minimal test array and processes it to determine output shape. For performance-critical code, subclasses should override this method with a direct calculation.

Source code in wandas/processing/base.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation.

    This method can be overridden by subclasses for efficiency.
    If not overridden, it will execute _process_array on a small test array
    to determine the output shape.

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape

    Notes
    -----
    The default implementation creates a minimal test array and processes it
    to determine output shape. For performance-critical code, subclasses should
    override this method with a direct calculation.
    """
    # Try to infer shape by executing _process_array on test data
    import numpy as np

    try:
        # Create minimal test array with input shape
        if len(input_shape) == 0:
            return input_shape

        # Create test input with correct dtype
        # Try complex first, fall back to float if needed
        test_input: Any = np.zeros(input_shape, dtype=np.complex128)

        # Process test input
        test_output: Any = self._process_array(test_input)

        # Return the shape of the output
        if isinstance(test_output, np.ndarray):
            return tuple(int(s) for s in test_output.shape)
        return input_shape
    except Exception as e:
        logger.warning(
            f"Failed to infer output shape for {self.__class__.__name__}: {e}. "
            "Please implement calculate_output_shape method."
        )
        raise NotImplementedError(
            f"Subclass {self.__class__.__name__} must implement "
            f"calculate_output_shape or ensure _process_array can be "
            f"called with test data."
        ) from e
process(data)

Execute operation and return result data shape is (channels, samples)

Source code in wandas/processing/base.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
def process(self, data: DaArray) -> DaArray:
    """
    Execute operation and return result
    data shape is (channels, samples)
    """
    # Add task as delayed processing with custom name for visualization
    logger.debug("Adding delayed operation to computation graph")

    # Create a wrapper function with the operation name
    # This allows Dask to use the operation name in the task graph
    wrapper = self._create_named_wrapper()
    delayed_func = delayed(wrapper, pure=self.pure)
    delayed_result = delayed_func(data)

    # Convert delayed result to dask array and return
    output_shape = self.calculate_output_shape(data.shape)
    return _da_from_delayed(delayed_result, shape=output_shape, dtype=data.dtype)
Functions
register_operation(operation_class)

Register a new operation type

Source code in wandas/processing/base.py
268
269
270
271
272
273
274
275
276
def register_operation(operation_class: type) -> None:
    """Register a new operation type"""

    if not issubclass(operation_class, AudioOperation):
        raise TypeError("Strategy class must inherit from AudioOperation.")
    if inspect.isabstract(operation_class):
        raise TypeError("Cannot register abstract AudioOperation class.")

    _OPERATION_REGISTRY[operation_class.name] = operation_class
get_operation(name)

Get operation class by name

Source code in wandas/processing/base.py
279
280
281
282
283
def get_operation(name: str) -> type[AudioOperation[Any, Any]]:
    """Get operation class by name"""
    if name not in _OPERATION_REGISTRY:
        raise ValueError(f"Unknown operation type: {name}")
    return _OPERATION_REGISTRY[name]
create_operation(name, sampling_rate, **params)

Create operation instance from name and parameters

Source code in wandas/processing/base.py
286
287
288
289
290
291
def create_operation(
    name: str, sampling_rate: float, **params: Any
) -> AudioOperation[Any, Any]:
    """Create operation instance from name and parameters"""
    operation_class = get_operation(name)
    return operation_class(sampling_rate, **params)

effects

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
HpssHarmonic

Bases: AudioOperation[NDArrayReal, NDArrayReal]

HPSS Harmonic operation

Source code in wandas/processing/effects.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class HpssHarmonic(AudioOperation[NDArrayReal, NDArrayReal]):
    """HPSS Harmonic operation"""

    name = "hpss_harmonic"

    def __init__(
        self,
        sampling_rate: float,
        **kwargs: Any,
    ):
        """
        Initialize HPSS Harmonic

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        self.kwargs = kwargs
        super().__init__(sampling_rate, **kwargs)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Hrm"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for HPSS Harmonic"""
        logger.debug(f"Applying HPSS Harmonic to array with shape: {x.shape}")
        result: NDArrayReal = effects.harmonic(x, **self.kwargs)
        logger.debug(
            f"HPSS Harmonic applied, returning result with shape: {result.shape}"
        )
        return result
Attributes
name = 'hpss_harmonic' class-attribute instance-attribute
kwargs = kwargs instance-attribute
Functions
__init__(sampling_rate, **kwargs)

Initialize HPSS Harmonic

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/effects.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def __init__(
    self,
    sampling_rate: float,
    **kwargs: Any,
):
    """
    Initialize HPSS Harmonic

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    self.kwargs = kwargs
    super().__init__(sampling_rate, **kwargs)
calculate_output_shape(input_shape)
Source code in wandas/processing/effects.py
38
39
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
41
42
43
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Hrm"
HpssPercussive

Bases: AudioOperation[NDArrayReal, NDArrayReal]

HPSS Percussive operation

Source code in wandas/processing/effects.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class HpssPercussive(AudioOperation[NDArrayReal, NDArrayReal]):
    """HPSS Percussive operation"""

    name = "hpss_percussive"

    def __init__(
        self,
        sampling_rate: float,
        **kwargs: Any,
    ):
        """
        Initialize HPSS Percussive

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        self.kwargs = kwargs
        super().__init__(sampling_rate, **kwargs)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Prc"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for HPSS Percussive"""
        logger.debug(f"Applying HPSS Percussive to array with shape: {x.shape}")
        result: NDArrayReal = effects.percussive(x, **self.kwargs)
        logger.debug(
            f"HPSS Percussive applied, returning result with shape: {result.shape}"
        )
        return result
Attributes
name = 'hpss_percussive' class-attribute instance-attribute
kwargs = kwargs instance-attribute
Functions
__init__(sampling_rate, **kwargs)

Initialize HPSS Percussive

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/effects.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def __init__(
    self,
    sampling_rate: float,
    **kwargs: Any,
):
    """
    Initialize HPSS Percussive

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    self.kwargs = kwargs
    super().__init__(sampling_rate, **kwargs)
calculate_output_shape(input_shape)
Source code in wandas/processing/effects.py
76
77
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
79
80
81
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Prc"
Normalize

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Signal normalization operation using librosa.util.normalize

Source code in wandas/processing/effects.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
class Normalize(AudioOperation[NDArrayReal, NDArrayReal]):
    """Signal normalization operation using librosa.util.normalize"""

    name = "normalize"

    def __init__(
        self,
        sampling_rate: float,
        norm: float | None = np.inf,
        axis: int | None = -1,
        threshold: float | None = None,
        fill: bool | None = None,
    ):
        """
        Initialize normalization operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        norm : float or np.inf, default=np.inf
            Norm type. Supported values:
            - np.inf: Maximum absolute value normalization
            - -np.inf: Minimum absolute value normalization
            - 0: Pseudo L0 normalization (divide by number of non-zero elements)
            - float: Lp norm
            - None: No normalization
        axis : int or None, default=-1
            Axis along which to normalize.
            - -1: Normalize along time axis (each channel independently)
            - None: Global normalization across all axes
            - int: Normalize along specified axis
        threshold : float or None, optional
            Threshold below which values are considered zero.
            If None, no threshold is applied.
        fill : bool or None, optional
            Value to fill when the norm is zero.
            If None, the zero vector remains zero.

        Raises
        ------
        ValueError
            If norm parameter is invalid or threshold is negative
        """
        # Validate norm parameter
        if norm is not None and not isinstance(norm, int | float):
            raise ValueError(
                f"Invalid normalization method\n"
                f"  Got: {type(norm).__name__} ({norm})\n"
                f"  Expected: float, int, np.inf, -np.inf, or None\n"
                f"Norm parameter must be a numeric value or None.\n"
                f"Common values: np.inf (max norm), 2 (L2 norm),\n"
                f"1 (L1 norm), 0 (pseudo L0)"
            )

        # Validate that norm is non-negative (except for -np.inf which is valid)
        if norm is not None and norm < 0 and not np.isneginf(norm):
            raise ValueError(
                f"Invalid normalization method\n"
                f"  Got: {norm}\n"
                f"  Expected: Non-negative value, np.inf, -np.inf, or None\n"
                f"Norm parameter must be non-negative (except -np.inf for min norm).\n"
                f"Common values: np.inf (max norm), 2 (L2 norm),\n"
                f"1 (L1 norm), 0 (pseudo L0)"
            )

        # Validate threshold
        if threshold is not None and threshold < 0:
            raise ValueError(
                f"Invalid threshold for normalization\n"
                f"  Got: {threshold}\n"
                f"  Expected: Non-negative value or None\n"
                f"Threshold must be non-negative.\n"
                f"Typical values: 0.0 (no threshold), 1e-10 (small threshold)"
            )

        super().__init__(
            sampling_rate, norm=norm, axis=axis, threshold=threshold, fill=fill
        )
        self.norm = norm
        self.axis = axis
        self.threshold = threshold
        self.fill = fill
        logger.debug(
            f"Initialized Normalize operation with norm={norm}, "
            f"axis={axis}, threshold={threshold}, fill={fill}"
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape (same as input)
        """
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "norm"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Perform normalization processing"""
        logger.debug(
            f"Applying normalization to array with shape: {x.shape}, "
            f"norm={self.norm}, axis={self.axis}"
        )

        # Apply librosa.util.normalize
        result: NDArrayReal = librosa_util.normalize(
            x, norm=self.norm, axis=self.axis, threshold=self.threshold, fill=self.fill
        )

        logger.debug(
            f"Normalization applied, returning result with shape: {result.shape}"
        )
        return result
Attributes
name = 'normalize' class-attribute instance-attribute
norm = norm instance-attribute
axis = axis instance-attribute
threshold = threshold instance-attribute
fill = fill instance-attribute
Functions
__init__(sampling_rate, norm=np.inf, axis=-1, threshold=None, fill=None)

Initialize normalization operation

Parameters

sampling_rate : float Sampling rate (Hz) norm : float or np.inf, default=np.inf Norm type. Supported values: - np.inf: Maximum absolute value normalization - -np.inf: Minimum absolute value normalization - 0: Pseudo L0 normalization (divide by number of non-zero elements) - float: Lp norm - None: No normalization axis : int or None, default=-1 Axis along which to normalize. - -1: Normalize along time axis (each channel independently) - None: Global normalization across all axes - int: Normalize along specified axis threshold : float or None, optional Threshold below which values are considered zero. If None, no threshold is applied. fill : bool or None, optional Value to fill when the norm is zero. If None, the zero vector remains zero.

Raises

ValueError If norm parameter is invalid or threshold is negative

Source code in wandas/processing/effects.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def __init__(
    self,
    sampling_rate: float,
    norm: float | None = np.inf,
    axis: int | None = -1,
    threshold: float | None = None,
    fill: bool | None = None,
):
    """
    Initialize normalization operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    norm : float or np.inf, default=np.inf
        Norm type. Supported values:
        - np.inf: Maximum absolute value normalization
        - -np.inf: Minimum absolute value normalization
        - 0: Pseudo L0 normalization (divide by number of non-zero elements)
        - float: Lp norm
        - None: No normalization
    axis : int or None, default=-1
        Axis along which to normalize.
        - -1: Normalize along time axis (each channel independently)
        - None: Global normalization across all axes
        - int: Normalize along specified axis
    threshold : float or None, optional
        Threshold below which values are considered zero.
        If None, no threshold is applied.
    fill : bool or None, optional
        Value to fill when the norm is zero.
        If None, the zero vector remains zero.

    Raises
    ------
    ValueError
        If norm parameter is invalid or threshold is negative
    """
    # Validate norm parameter
    if norm is not None and not isinstance(norm, int | float):
        raise ValueError(
            f"Invalid normalization method\n"
            f"  Got: {type(norm).__name__} ({norm})\n"
            f"  Expected: float, int, np.inf, -np.inf, or None\n"
            f"Norm parameter must be a numeric value or None.\n"
            f"Common values: np.inf (max norm), 2 (L2 norm),\n"
            f"1 (L1 norm), 0 (pseudo L0)"
        )

    # Validate that norm is non-negative (except for -np.inf which is valid)
    if norm is not None and norm < 0 and not np.isneginf(norm):
        raise ValueError(
            f"Invalid normalization method\n"
            f"  Got: {norm}\n"
            f"  Expected: Non-negative value, np.inf, -np.inf, or None\n"
            f"Norm parameter must be non-negative (except -np.inf for min norm).\n"
            f"Common values: np.inf (max norm), 2 (L2 norm),\n"
            f"1 (L1 norm), 0 (pseudo L0)"
        )

    # Validate threshold
    if threshold is not None and threshold < 0:
        raise ValueError(
            f"Invalid threshold for normalization\n"
            f"  Got: {threshold}\n"
            f"  Expected: Non-negative value or None\n"
            f"Threshold must be non-negative.\n"
            f"Typical values: 0.0 (no threshold), 1e-10 (small threshold)"
        )

    super().__init__(
        sampling_rate, norm=norm, axis=axis, threshold=threshold, fill=fill
    )
    self.norm = norm
    self.axis = axis
    self.threshold = threshold
    self.fill = fill
    logger.debug(
        f"Initialized Normalize operation with norm={norm}, "
        f"axis={axis}, threshold={threshold}, fill={fill}"
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape (same as input)

Source code in wandas/processing/effects.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape (same as input)
    """
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
197
198
199
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "norm"
RemoveDC

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Remove DC component (DC offset) from the signal.

This operation removes the DC component by subtracting the mean value from each channel, centering the signal around zero.

Source code in wandas/processing/effects.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
class RemoveDC(AudioOperation[NDArrayReal, NDArrayReal]):
    """Remove DC component (DC offset) from the signal.

    This operation removes the DC component by subtracting the mean value
    from each channel, centering the signal around zero.
    """

    name = "remove_dc"

    def __init__(self, sampling_rate: float):
        """Initialize DC removal operation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        super().__init__(sampling_rate)
        logger.debug("Initialized RemoveDC operation")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """Calculate output data shape after operation.

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape (same as input)
        """
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "dcRM"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Perform DC removal processing.

        Parameters
        ----------
        x : NDArrayReal
            Input signal array (channels, samples)

        Returns
        -------
        NDArrayReal
            Signal with DC component removed
        """
        logger.debug(f"Removing DC component from array with shape: {x.shape}")

        # Subtract mean along time axis (axis=1 for channel data)
        mean_values = x.mean(axis=-1, keepdims=True)
        result: NDArrayReal = x - mean_values

        logger.debug(f"DC removal applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'remove_dc' class-attribute instance-attribute
Functions
__init__(sampling_rate)

Initialize DC removal operation.

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/effects.py
228
229
230
231
232
233
234
235
236
237
def __init__(self, sampling_rate: float):
    """Initialize DC removal operation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    super().__init__(sampling_rate)
    logger.debug("Initialized RemoveDC operation")
calculate_output_shape(input_shape)

Calculate output data shape after operation.

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape (same as input)

Source code in wandas/processing/effects.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """Calculate output data shape after operation.

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape (same as input)
    """
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
254
255
256
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "dcRM"
AddWithSNR

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Addition operation considering SNR

Source code in wandas/processing/effects.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
class AddWithSNR(AudioOperation[NDArrayReal, NDArrayReal]):
    """Addition operation considering SNR"""

    name = "add_with_snr"

    def __init__(self, sampling_rate: float, other: DaArray, snr: float = 1.0):
        """
        Initialize addition operation considering SNR

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        other : DaArray
            Noise signal to add (channel-frame format)
        snr : float
            Signal-to-noise ratio (dB)
        """
        super().__init__(sampling_rate, other=other, snr=snr)

        self.other = other
        self.snr = snr
        logger.debug(f"Initialized AddWithSNR operation with SNR: {snr} dB")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape (same as input)
        """
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "+SNR"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Perform addition processing considering SNR"""
        logger.debug(f"Applying SNR-based addition with shape: {x.shape}")
        other: NDArrayReal = self.other.compute()

        # Use multi-channel versions of calculate_rms and calculate_desired_noise_rms
        clean_rms = util.calculate_rms(x)
        other_rms = util.calculate_rms(other)

        # Adjust noise gain based on specified SNR (apply per channel)
        desired_noise_rms = util.calculate_desired_noise_rms(clean_rms, self.snr)

        # Apply gain per channel using broadcasting
        gain = desired_noise_rms / other_rms
        # Add adjusted noise to signal
        result: NDArrayReal = x + other * gain
        return result
Attributes
name = 'add_with_snr' class-attribute instance-attribute
other = other instance-attribute
snr = snr instance-attribute
Functions
__init__(sampling_rate, other, snr=1.0)

Initialize addition operation considering SNR

Parameters

sampling_rate : float Sampling rate (Hz) other : DaArray Noise signal to add (channel-frame format) snr : float Signal-to-noise ratio (dB)

Source code in wandas/processing/effects.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def __init__(self, sampling_rate: float, other: DaArray, snr: float = 1.0):
    """
    Initialize addition operation considering SNR

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    other : DaArray
        Noise signal to add (channel-frame format)
    snr : float
        Signal-to-noise ratio (dB)
    """
    super().__init__(sampling_rate, other=other, snr=snr)

    self.other = other
    self.snr = snr
    logger.debug(f"Initialized AddWithSNR operation with SNR: {snr} dB")
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape (same as input)

Source code in wandas/processing/effects.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape (same as input)
    """
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
321
322
323
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "+SNR"
Fade

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Fade operation using a Tukey (tapered cosine) window.

This operation applies symmetric fade-in and fade-out with the same duration. The Tukey window alpha parameter is computed from the fade duration so that the tapered portion equals the requested fade length at each end.

Source code in wandas/processing/effects.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
class Fade(AudioOperation[NDArrayReal, NDArrayReal]):
    """Fade operation using a Tukey (tapered cosine) window.

    This operation applies symmetric fade-in and fade-out with the same
    duration. The Tukey window alpha parameter is computed from the fade
    duration so that the tapered portion equals the requested fade length
    at each end.
    """

    name = "fade"

    def __init__(self, sampling_rate: float, fade_ms: float = 50) -> None:
        self.fade_ms = float(fade_ms)
        # Precompute fade length in samples at construction time
        self.fade_len = int(round(self.fade_ms * float(sampling_rate) / 1000.0))
        super().__init__(sampling_rate, fade_ms=fade_ms)

    def validate_params(self) -> None:
        if self.fade_ms < 0:
            raise ValueError("fade_ms must be non-negative")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "fade"

    @staticmethod
    def calculate_tukey_alpha(fade_len: int, n_samples: int) -> float:
        """Calculate Tukey window alpha parameter from fade length.

        The alpha parameter determines what fraction of the window is tapered.
        For symmetric fade-in/fade-out, alpha = 2 * fade_len / n_samples ensures
        that each side's taper has exactly fade_len samples.

        Parameters
        ----------
        fade_len : int
            Desired fade length in samples for each end (in and out).
        n_samples : int
            Total number of samples in the signal.

        Returns
        -------
        float
            Alpha parameter for scipy.signal.windows.tukey, clamped to [0, 1].

        Examples
        --------
        >>> Fade.calculate_tukey_alpha(fade_len=20, n_samples=200)
        0.2
        >>> Fade.calculate_tukey_alpha(fade_len=100, n_samples=100)
        1.0
        """
        alpha = float(2 * fade_len) / float(n_samples)
        return min(1.0, alpha)

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        logger.debug(f"Applying Tukey Fade to array with shape: {x.shape}")

        arr = x
        if arr.ndim == 1:
            arr = arr.reshape(1, -1)

        n_samples = int(arr.shape[-1])

        # If no fade requested, return input
        if self.fade_len <= 0:
            return arr

        if 2 * self.fade_len >= n_samples:
            raise ValueError(
                "Fade length too long: 2*fade_ms must be less than signal length"
            )

        # Calculate Tukey window alpha parameter
        alpha = self.calculate_tukey_alpha(self.fade_len, n_samples)

        # Create tukey window (numpy) and apply
        env = sp_windows.tukey(n_samples, alpha=alpha)

        result: NDArrayReal = arr * env[None, :]
        logger.debug("Tukey fade applied")
        return result
Attributes
name = 'fade' class-attribute instance-attribute
fade_ms = float(fade_ms) instance-attribute
fade_len = int(round(self.fade_ms * float(sampling_rate) / 1000.0)) instance-attribute
Functions
__init__(sampling_rate, fade_ms=50)
Source code in wandas/processing/effects.py
355
356
357
358
359
def __init__(self, sampling_rate: float, fade_ms: float = 50) -> None:
    self.fade_ms = float(fade_ms)
    # Precompute fade length in samples at construction time
    self.fade_len = int(round(self.fade_ms * float(sampling_rate) / 1000.0))
    super().__init__(sampling_rate, fade_ms=fade_ms)
validate_params()
Source code in wandas/processing/effects.py
361
362
363
def validate_params(self) -> None:
    if self.fade_ms < 0:
        raise ValueError("fade_ms must be non-negative")
calculate_output_shape(input_shape)
Source code in wandas/processing/effects.py
365
366
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/effects.py
368
369
370
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "fade"
calculate_tukey_alpha(fade_len, n_samples) staticmethod

Calculate Tukey window alpha parameter from fade length.

The alpha parameter determines what fraction of the window is tapered. For symmetric fade-in/fade-out, alpha = 2 * fade_len / n_samples ensures that each side's taper has exactly fade_len samples.

Parameters

fade_len : int Desired fade length in samples for each end (in and out). n_samples : int Total number of samples in the signal.

Returns

float Alpha parameter for scipy.signal.windows.tukey, clamped to [0, 1].

Examples

Fade.calculate_tukey_alpha(fade_len=20, n_samples=200) 0.2 Fade.calculate_tukey_alpha(fade_len=100, n_samples=100) 1.0

Source code in wandas/processing/effects.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@staticmethod
def calculate_tukey_alpha(fade_len: int, n_samples: int) -> float:
    """Calculate Tukey window alpha parameter from fade length.

    The alpha parameter determines what fraction of the window is tapered.
    For symmetric fade-in/fade-out, alpha = 2 * fade_len / n_samples ensures
    that each side's taper has exactly fade_len samples.

    Parameters
    ----------
    fade_len : int
        Desired fade length in samples for each end (in and out).
    n_samples : int
        Total number of samples in the signal.

    Returns
    -------
    float
        Alpha parameter for scipy.signal.windows.tukey, clamped to [0, 1].

    Examples
    --------
    >>> Fade.calculate_tukey_alpha(fade_len=20, n_samples=200)
    0.2
    >>> Fade.calculate_tukey_alpha(fade_len=100, n_samples=100)
    1.0
    """
    alpha = float(2 * fade_len) / float(n_samples)
    return min(1.0, alpha)
Functions
Modules

filters

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
HighPassFilter

Bases: AudioOperation[NDArrayReal, NDArrayReal]

High-pass filter operation

Source code in wandas/processing/filters.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class HighPassFilter(AudioOperation[NDArrayReal, NDArrayReal]):
    """High-pass filter operation"""

    name = "highpass_filter"
    a: NDArrayReal
    b: NDArrayReal

    def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
        """
        Initialize high-pass filter

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        cutoff : float
            Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
            (sampling_rate / 2).
        order : int, optional
            Filter order, default is 4

        Raises
        ------
        ValueError
            If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
        """
        self.cutoff = cutoff
        self.order = order
        super().__init__(sampling_rate, cutoff=cutoff, order=order)

    def validate_params(self) -> None:
        """Validate parameters"""
        nyquist = self.sampling_rate / 2
        if self.cutoff <= 0 or self.cutoff >= nyquist:
            raise ValueError(
                f"Cutoff frequency out of valid range\n"
                f"  Got: {self.cutoff} Hz\n"
                f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
                f"The Nyquist frequency is half the sampling rate\n"
                f"  ({self.sampling_rate} Hz).\n"
                f"Filters cannot work above this limit due to aliasing.\n"
                f"Solutions:\n"
                f"  - Use a cutoff frequency below {nyquist} Hz\n"
                f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
                f"    using resample()"
            )

    def _setup_processor(self) -> None:
        """Set up high-pass filter processor"""
        # Calculate filter coefficients (once) - safely retrieve from instance variables
        nyquist = 0.5 * self.sampling_rate
        normal_cutoff = self.cutoff / nyquist

        # Precompute and save filter coefficients
        self.b, self.a = signal.butter(self.order, normal_cutoff, btype="high")  # type: ignore [unused-ignore]
        logger.debug(f"Highpass filter coefficients calculated: b={self.b}, a={self.a}")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "hpf"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Filter processing wrapped with @dask.delayed"""
        logger.debug(f"Applying highpass filter to array with shape: {x.shape}")
        result: NDArrayReal = signal.filtfilt(self.b, self.a, x, axis=1)
        logger.debug(f"Filter applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'highpass_filter' class-attribute instance-attribute
a instance-attribute
b instance-attribute
cutoff = cutoff instance-attribute
order = order instance-attribute
Functions
__init__(sampling_rate, cutoff, order=4)

Initialize high-pass filter

Parameters

sampling_rate : float Sampling rate (Hz) cutoff : float Cutoff frequency (Hz). Must be between 0 and Nyquist frequency (sampling_rate / 2). order : int, optional Filter order, default is 4

Raises

ValueError If cutoff frequency is not within valid range (0 < cutoff < Nyquist)

Source code in wandas/processing/filters.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
    """
    Initialize high-pass filter

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    cutoff : float
        Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
        (sampling_rate / 2).
    order : int, optional
        Filter order, default is 4

    Raises
    ------
    ValueError
        If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
    """
    self.cutoff = cutoff
    self.order = order
    super().__init__(sampling_rate, cutoff=cutoff, order=order)
validate_params()

Validate parameters

Source code in wandas/processing/filters.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def validate_params(self) -> None:
    """Validate parameters"""
    nyquist = self.sampling_rate / 2
    if self.cutoff <= 0 or self.cutoff >= nyquist:
        raise ValueError(
            f"Cutoff frequency out of valid range\n"
            f"  Got: {self.cutoff} Hz\n"
            f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
            f"The Nyquist frequency is half the sampling rate\n"
            f"  ({self.sampling_rate} Hz).\n"
            f"Filters cannot work above this limit due to aliasing.\n"
            f"Solutions:\n"
            f"  - Use a cutoff frequency below {nyquist} Hz\n"
            f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
            f"    using resample()"
        )
calculate_output_shape(input_shape)
Source code in wandas/processing/filters.py
70
71
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/filters.py
73
74
75
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "hpf"
LowPassFilter

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Low-pass filter operation

Source code in wandas/processing/filters.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
class LowPassFilter(AudioOperation[NDArrayReal, NDArrayReal]):
    """Low-pass filter operation"""

    name = "lowpass_filter"
    a: NDArrayReal
    b: NDArrayReal

    def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
        """
        Initialize low-pass filter

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        cutoff : float
            Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
            (sampling_rate / 2).
        order : int, optional
            Filter order, default is 4

        Raises
        ------
        ValueError
            If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
        """
        self.cutoff = cutoff
        self.order = order
        super().__init__(sampling_rate, cutoff=cutoff, order=order)

    def validate_params(self) -> None:
        """Validate parameters"""
        nyquist = self.sampling_rate / 2
        if self.cutoff <= 0 or self.cutoff >= nyquist:
            raise ValueError(
                f"Cutoff frequency out of valid range\n"
                f"  Got: {self.cutoff} Hz\n"
                f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
                f"The Nyquist frequency is half the sampling rate\n"
                f"  ({self.sampling_rate} Hz).\n"
                f"Filters cannot work above this limit due to aliasing.\n"
                f"Solutions:\n"
                f"  - Use a cutoff frequency below {nyquist} Hz\n"
                f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
                f"    using resample()"
            )

    def _setup_processor(self) -> None:
        """Set up low-pass filter processor"""
        nyquist = 0.5 * self.sampling_rate
        normal_cutoff = self.cutoff / nyquist

        # Precompute and save filter coefficients
        self.b, self.a = signal.butter(self.order, normal_cutoff, btype="low")  # type: ignore [unused-ignore]
        logger.debug(f"Lowpass filter coefficients calculated: b={self.b}, a={self.a}")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "lpf"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Filter processing wrapped with @dask.delayed"""
        logger.debug(f"Applying lowpass filter to array with shape: {x.shape}")
        result: NDArrayReal = signal.filtfilt(self.b, self.a, x, axis=1)

        logger.debug(f"Filter applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'lowpass_filter' class-attribute instance-attribute
a instance-attribute
b instance-attribute
cutoff = cutoff instance-attribute
order = order instance-attribute
Functions
__init__(sampling_rate, cutoff, order=4)

Initialize low-pass filter

Parameters

sampling_rate : float Sampling rate (Hz) cutoff : float Cutoff frequency (Hz). Must be between 0 and Nyquist frequency (sampling_rate / 2). order : int, optional Filter order, default is 4

Raises

ValueError If cutoff frequency is not within valid range (0 < cutoff < Nyquist)

Source code in wandas/processing/filters.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def __init__(self, sampling_rate: float, cutoff: float, order: int = 4):
    """
    Initialize low-pass filter

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    cutoff : float
        Cutoff frequency (Hz). Must be between 0 and Nyquist frequency
        (sampling_rate / 2).
    order : int, optional
        Filter order, default is 4

    Raises
    ------
    ValueError
        If cutoff frequency is not within valid range (0 < cutoff < Nyquist)
    """
    self.cutoff = cutoff
    self.order = order
    super().__init__(sampling_rate, cutoff=cutoff, order=order)
validate_params()

Validate parameters

Source code in wandas/processing/filters.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def validate_params(self) -> None:
    """Validate parameters"""
    nyquist = self.sampling_rate / 2
    if self.cutoff <= 0 or self.cutoff >= nyquist:
        raise ValueError(
            f"Cutoff frequency out of valid range\n"
            f"  Got: {self.cutoff} Hz\n"
            f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
            f"The Nyquist frequency is half the sampling rate\n"
            f"  ({self.sampling_rate} Hz).\n"
            f"Filters cannot work above this limit due to aliasing.\n"
            f"Solutions:\n"
            f"  - Use a cutoff frequency below {nyquist} Hz\n"
            f"  - Or increase sampling rate above {self.cutoff * 2} Hz\n"
            f"    using resample()"
        )
calculate_output_shape(input_shape)
Source code in wandas/processing/filters.py
141
142
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/filters.py
144
145
146
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "lpf"
BandPassFilter

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Band-pass filter operation

Source code in wandas/processing/filters.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
class BandPassFilter(AudioOperation[NDArrayReal, NDArrayReal]):
    """Band-pass filter operation"""

    name = "bandpass_filter"
    a: NDArrayReal
    b: NDArrayReal

    def __init__(
        self,
        sampling_rate: float,
        low_cutoff: float,
        high_cutoff: float,
        order: int = 4,
    ):
        """
        Initialize band-pass filter

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        low_cutoff : float
            Lower cutoff frequency (Hz). Must be between 0 and Nyquist frequency.
        high_cutoff : float
            Higher cutoff frequency (Hz). Must be between 0 and Nyquist frequency
            and greater than low_cutoff.
        order : int, optional
            Filter order, default is 4

        Raises
        ------
        ValueError
            If either cutoff frequency is not within valid range (0 < cutoff < Nyquist),
            or if low_cutoff >= high_cutoff
        """
        self.low_cutoff = low_cutoff
        self.high_cutoff = high_cutoff
        self.order = order
        super().__init__(
            sampling_rate, low_cutoff=low_cutoff, high_cutoff=high_cutoff, order=order
        )

    def validate_params(self) -> None:
        """Validate parameters"""
        nyquist = self.sampling_rate / 2
        if self.low_cutoff <= 0 or self.low_cutoff >= nyquist:
            raise ValueError(
                f"Lower cutoff frequency out of valid range\n"
                f"  Got: {self.low_cutoff} Hz\n"
                f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
                f"The Nyquist frequency is half the sampling rate\n"
                f"  ({self.sampling_rate} Hz).\n"
                f"Filters cannot work above this limit due to aliasing.\n"
                f"Use a lower cutoff frequency below {nyquist} Hz"
            )
        if self.high_cutoff <= 0 or self.high_cutoff >= nyquist:
            raise ValueError(
                f"Higher cutoff frequency out of valid range\n"
                f"  Got: {self.high_cutoff} Hz\n"
                f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
                f"The Nyquist frequency is half the sampling rate\n"
                f"  ({self.sampling_rate} Hz).\n"
                f"Filters cannot work above this limit due to aliasing.\n"
                f"Use a cutoff frequency below {nyquist} Hz"
            )
        if self.low_cutoff >= self.high_cutoff:
            raise ValueError(
                f"Invalid bandpass filter cutoff frequencies\n"
                f"  Lower cutoff: {self.low_cutoff} Hz\n"
                f"  Higher cutoff: {self.high_cutoff} Hz\n"
                f"  Problem: Lower cutoff must be less than higher cutoff\n"
                f"A bandpass filter passes frequencies between low and high\n"
                f"  cutoffs.\n"
                f"Ensure low_cutoff < high_cutoff\n"
                f"  (e.g., low_cutoff=100, high_cutoff=1000)"
            )

    def _setup_processor(self) -> None:
        """Set up band-pass filter processor"""
        nyquist = 0.5 * self.sampling_rate
        low_normal_cutoff = self.low_cutoff / nyquist
        high_normal_cutoff = self.high_cutoff / nyquist

        # Precompute and save filter coefficients
        self.b, self.a = signal.butter(
            self.order, [low_normal_cutoff, high_normal_cutoff], btype="band"
        )  # type: ignore [unused-ignore]
        logger.debug(f"Bandpass filter coefficients calculated: b={self.b}, a={self.a}")

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "bpf"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Filter processing wrapped with @dask.delayed"""
        logger.debug(f"Applying bandpass filter to array with shape: {x.shape}")
        result: NDArrayReal = signal.filtfilt(self.b, self.a, x, axis=1)
        logger.debug(f"Filter applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'bandpass_filter' class-attribute instance-attribute
a instance-attribute
b instance-attribute
low_cutoff = low_cutoff instance-attribute
high_cutoff = high_cutoff instance-attribute
order = order instance-attribute
Functions
__init__(sampling_rate, low_cutoff, high_cutoff, order=4)

Initialize band-pass filter

Parameters

sampling_rate : float Sampling rate (Hz) low_cutoff : float Lower cutoff frequency (Hz). Must be between 0 and Nyquist frequency. high_cutoff : float Higher cutoff frequency (Hz). Must be between 0 and Nyquist frequency and greater than low_cutoff. order : int, optional Filter order, default is 4

Raises

ValueError If either cutoff frequency is not within valid range (0 < cutoff < Nyquist), or if low_cutoff >= high_cutoff

Source code in wandas/processing/filters.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def __init__(
    self,
    sampling_rate: float,
    low_cutoff: float,
    high_cutoff: float,
    order: int = 4,
):
    """
    Initialize band-pass filter

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    low_cutoff : float
        Lower cutoff frequency (Hz). Must be between 0 and Nyquist frequency.
    high_cutoff : float
        Higher cutoff frequency (Hz). Must be between 0 and Nyquist frequency
        and greater than low_cutoff.
    order : int, optional
        Filter order, default is 4

    Raises
    ------
    ValueError
        If either cutoff frequency is not within valid range (0 < cutoff < Nyquist),
        or if low_cutoff >= high_cutoff
    """
    self.low_cutoff = low_cutoff
    self.high_cutoff = high_cutoff
    self.order = order
    super().__init__(
        sampling_rate, low_cutoff=low_cutoff, high_cutoff=high_cutoff, order=order
    )
validate_params()

Validate parameters

Source code in wandas/processing/filters.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def validate_params(self) -> None:
    """Validate parameters"""
    nyquist = self.sampling_rate / 2
    if self.low_cutoff <= 0 or self.low_cutoff >= nyquist:
        raise ValueError(
            f"Lower cutoff frequency out of valid range\n"
            f"  Got: {self.low_cutoff} Hz\n"
            f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
            f"The Nyquist frequency is half the sampling rate\n"
            f"  ({self.sampling_rate} Hz).\n"
            f"Filters cannot work above this limit due to aliasing.\n"
            f"Use a lower cutoff frequency below {nyquist} Hz"
        )
    if self.high_cutoff <= 0 or self.high_cutoff >= nyquist:
        raise ValueError(
            f"Higher cutoff frequency out of valid range\n"
            f"  Got: {self.high_cutoff} Hz\n"
            f"  Valid range: 0 < cutoff < {nyquist} Hz (Nyquist frequency)\n"
            f"The Nyquist frequency is half the sampling rate\n"
            f"  ({self.sampling_rate} Hz).\n"
            f"Filters cannot work above this limit due to aliasing.\n"
            f"Use a cutoff frequency below {nyquist} Hz"
        )
    if self.low_cutoff >= self.high_cutoff:
        raise ValueError(
            f"Invalid bandpass filter cutoff frequencies\n"
            f"  Lower cutoff: {self.low_cutoff} Hz\n"
            f"  Higher cutoff: {self.high_cutoff} Hz\n"
            f"  Problem: Lower cutoff must be less than higher cutoff\n"
            f"A bandpass filter passes frequencies between low and high\n"
            f"  cutoffs.\n"
            f"Ensure low_cutoff < high_cutoff\n"
            f"  (e.g., low_cutoff=100, high_cutoff=1000)"
        )
calculate_output_shape(input_shape)
Source code in wandas/processing/filters.py
246
247
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/filters.py
249
250
251
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "bpf"
AWeighting

Bases: AudioOperation[NDArrayReal, NDArrayReal]

A-weighting filter operation

Source code in wandas/processing/filters.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
class AWeighting(AudioOperation[NDArrayReal, NDArrayReal]):
    """A-weighting filter operation"""

    name = "a_weighting"

    def __init__(self, sampling_rate: float):
        """
        Initialize A-weighting filter

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        super().__init__(sampling_rate)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        return input_shape

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Aw"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for A-weighting filter"""
        logger.debug(f"Applying A-weighting to array with shape: {x.shape}")
        result = A_weight(x, self.sampling_rate)

        # Handle case where A_weight returns a tuple
        if isinstance(result, tuple):
            # Use the first element of the tuple
            result = result[0]

        logger.debug(
            f"A-weighting applied, returning result with shape: {result.shape}"
        )
        return np.array(result)
Attributes
name = 'a_weighting' class-attribute instance-attribute
Functions
__init__(sampling_rate)

Initialize A-weighting filter

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/filters.py
266
267
268
269
270
271
272
273
274
275
def __init__(self, sampling_rate: float):
    """
    Initialize A-weighting filter

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    super().__init__(sampling_rate)
calculate_output_shape(input_shape)
Source code in wandas/processing/filters.py
277
278
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    return input_shape
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/filters.py
280
281
282
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Aw"
Functions

psychoacoustic

Psychoacoustic metrics processing operations.

This module provides psychoacoustic metrics operations for audio signals, including loudness calculation using standardized methods.

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
LoudnessZwtv

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

This operation computes the loudness of non-stationary signals according to the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's implementation of the standardized loudness calculation.

The loudness is calculated in sones, a unit of perceived loudness where a doubling of sones corresponds to a doubling of perceived loudness.

Parameters

sampling_rate : float Sampling rate in Hz. The signal should be sampled at a rate appropriate for the analysis (typically 44100 Hz or 48000 Hz for audio). field_type : str, default="free" Type of sound field. Options: - 'free': Free field (sound arriving from a specific direction) - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

Attributes

name : str Operation name: "loudness_zwtv" field_type : str The sound field type used for calculation

Examples

Calculate loudness for a signal:

import wandas as wd signal = wd.read_wav("audio.wav") loudness = signal.loudness_zwtv(field_type="free")

Notes
  • The output contains time-varying loudness values in sones
  • For mono signals, the loudness is calculated directly
  • For multi-channel signals, loudness is calculated per channel
  • The method follows ISO 532-1:2017 standard for time-varying loudness
  • Typical loudness values: 1 sone ≈ 40 phon (loudness level)
References

.. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method" .. [2] MoSQITo documentation: https://mosqito.readthedocs.io/en/latest/

Source code in wandas/processing/psychoacoustic.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
class LoudnessZwtv(AudioOperation[NDArrayReal, NDArrayReal]):
    """
    Calculate time-varying loudness using Zwicker method (ISO 532-1:2017).

    This operation computes the loudness of non-stationary signals according to
    the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's
    implementation of the standardized loudness calculation.

    The loudness is calculated in sones, a unit of perceived loudness where a doubling
    of sones corresponds to a doubling of perceived loudness.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate in Hz. The signal should be sampled at a rate appropriate
        for the analysis (typically 44100 Hz or 48000 Hz for audio).
    field_type : str, default="free"
        Type of sound field. Options:
        - 'free': Free field (sound arriving from a specific direction)
        - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

    Attributes
    ----------
    name : str
        Operation name: "loudness_zwtv"
    field_type : str
        The sound field type used for calculation

    Examples
    --------
    Calculate loudness for a signal:
    >>> import wandas as wd
    >>> signal = wd.read_wav("audio.wav")
    >>> loudness = signal.loudness_zwtv(field_type="free")

    Notes
    -----
    - The output contains time-varying loudness values in sones
    - For mono signals, the loudness is calculated directly
    - For multi-channel signals, loudness is calculated per channel
    - The method follows ISO 532-1:2017 standard for time-varying loudness
    - Typical loudness values: 1 sone ≈ 40 phon (loudness level)

    References
    ----------
    .. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
           Part 1: Zwicker method"
    .. [2] MoSQITo documentation:
           https://mosqito.readthedocs.io/en/latest/
    """

    name = "loudness_zwtv"

    def __init__(self, sampling_rate: float, field_type: str = "free"):
        """
        Initialize Loudness calculation operation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        field_type : str, default="free"
            Type of sound field ('free' or 'diffuse')
        """
        self.field_type = field_type
        super().__init__(sampling_rate, field_type=field_type)

    def validate_params(self) -> None:
        """
        Validate parameters.

        Raises
        ------
        ValueError
            If field_type is not 'free' or 'diffuse'
        """
        if self.field_type not in ("free", "diffuse"):
            raise ValueError(
                f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
            )

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Update sampling rate based on MoSQITo's time resolution.

        The Zwicker method uses approximately 2ms time steps,
        which corresponds to 500 Hz sampling rate, independent of
        the input sampling rate.

        Returns
        -------
        dict
            Metadata updates with new sampling rate

        Notes
        -----
        All necessary parameters are provided at initialization.
        The output sampling rate is always 500 Hz regardless of input.
        """
        return {"sampling_rate": 500.0}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation.

        The loudness calculation produces a time-varying output where the time
        resolution depends on the algorithm's internal processing. The exact
        output length is determined dynamically by the loudness_zwtv function.

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape. For loudness, we return a placeholder shape
            since the actual length is determined by the algorithm.
            The shape will be (channels, time_samples) where time_samples
            depends on the input length and algorithm parameters.
        """
        # Return a placeholder shape - the actual shape will be determined
        # after processing since loudness_zwtv determines the time resolution
        # For now, we estimate based on typical behavior (approx 2ms time steps)
        n_channels = input_shape[0] if len(input_shape) > 1 else 1
        # Rough estimate: one loudness value per 2ms (0.002s)
        estimated_time_samples = int(input_shape[-1] / (self.sampling_rate * 0.002))
        return (n_channels, estimated_time_samples)

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """
        Process array to calculate loudness.

        This method calculates the time-varying loudness for each channel
        of the input signal using the Zwicker method.

        Parameters
        ----------
        x : NDArrayReal
            Input signal array with shape (channels, samples) or (samples,)

        Returns
        -------
        NDArrayReal
            Time-varying loudness in sones for each channel.
            Shape: (channels, time_samples)

        Notes
        -----
        The function processes each channel independently and returns
        the loudness values. The time axis information is not returned
        here but can be reconstructed based on the MoSQITo algorithm's
        behavior (typically 2ms time steps).
        """
        logger.debug(
            f"Calculating loudness for signal with shape: {x.shape}, "
            f"field_type: {self.field_type}"
        )

        # Handle 1D input (single channel)
        if x.ndim == 1:
            x = x.reshape(1, -1)

        n_channels = x.shape[0]
        loudness_results = []

        for ch in range(n_channels):
            channel_data = x[ch, :]

            # Ensure channel_data is a contiguous 1D NumPy array
            channel_data = np.asarray(channel_data).ravel()

            # Call MoSQITo's loudness_zwtv function
            # Returns: N (loudness), N_spec (specific loudness),
            #          bark_axis, time_axis
            loudness_n, _, _, _ = loudness_zwtv_mosqito(
                channel_data, self.sampling_rate, field_type=self.field_type
            )

            loudness_results.append(loudness_n)

            logger.debug(
                f"Channel {ch}: Calculated loudness with "
                f"{len(loudness_n)} time points, "
                f"max loudness: {np.max(loudness_n):.2f} sones"
            )

        # Stack results
        result: NDArrayReal = np.stack(loudness_results, axis=0)

        logger.debug(f"Loudness calculation complete, output shape: {result.shape}")
        return result
Attributes
name = 'loudness_zwtv' class-attribute instance-attribute
field_type = field_type instance-attribute
Functions
__init__(sampling_rate, field_type='free')

Initialize Loudness calculation operation.

Parameters

sampling_rate : float Sampling rate (Hz) field_type : str, default="free" Type of sound field ('free' or 'diffuse')

Source code in wandas/processing/psychoacoustic.py
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(self, sampling_rate: float, field_type: str = "free"):
    """
    Initialize Loudness calculation operation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    field_type : str, default="free"
        Type of sound field ('free' or 'diffuse')
    """
    self.field_type = field_type
    super().__init__(sampling_rate, field_type=field_type)
validate_params()

Validate parameters.

Raises

ValueError If field_type is not 'free' or 'diffuse'

Source code in wandas/processing/psychoacoustic.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def validate_params(self) -> None:
    """
    Validate parameters.

    Raises
    ------
    ValueError
        If field_type is not 'free' or 'diffuse'
    """
    if self.field_type not in ("free", "diffuse"):
        raise ValueError(
            f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
        )
get_metadata_updates()

Update sampling rate based on MoSQITo's time resolution.

The Zwicker method uses approximately 2ms time steps, which corresponds to 500 Hz sampling rate, independent of the input sampling rate.

Returns

dict Metadata updates with new sampling rate

Notes

All necessary parameters are provided at initialization. The output sampling rate is always 500 Hz regardless of input.

Source code in wandas/processing/psychoacoustic.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Update sampling rate based on MoSQITo's time resolution.

    The Zwicker method uses approximately 2ms time steps,
    which corresponds to 500 Hz sampling rate, independent of
    the input sampling rate.

    Returns
    -------
    dict
        Metadata updates with new sampling rate

    Notes
    -----
    All necessary parameters are provided at initialization.
    The output sampling rate is always 500 Hz regardless of input.
    """
    return {"sampling_rate": 500.0}
calculate_output_shape(input_shape)

Calculate output data shape after operation.

The loudness calculation produces a time-varying output where the time resolution depends on the algorithm's internal processing. The exact output length is determined dynamically by the loudness_zwtv function.

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape. For loudness, we return a placeholder shape since the actual length is determined by the algorithm. The shape will be (channels, time_samples) where time_samples depends on the input length and algorithm parameters.

Source code in wandas/processing/psychoacoustic.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation.

    The loudness calculation produces a time-varying output where the time
    resolution depends on the algorithm's internal processing. The exact
    output length is determined dynamically by the loudness_zwtv function.

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape. For loudness, we return a placeholder shape
        since the actual length is determined by the algorithm.
        The shape will be (channels, time_samples) where time_samples
        depends on the input length and algorithm parameters.
    """
    # Return a placeholder shape - the actual shape will be determined
    # after processing since loudness_zwtv determines the time resolution
    # For now, we estimate based on typical behavior (approx 2ms time steps)
    n_channels = input_shape[0] if len(input_shape) > 1 else 1
    # Rough estimate: one loudness value per 2ms (0.002s)
    estimated_time_samples = int(input_shape[-1] / (self.sampling_rate * 0.002))
    return (n_channels, estimated_time_samples)
LoudnessZwst

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

This operation computes the loudness of stationary (steady) signals according to the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's implementation of the standardized loudness calculation for steady signals.

The loudness is calculated in sones, a unit of perceived loudness where a doubling of sones corresponds to a doubling of perceived loudness.

Parameters

sampling_rate : float Sampling rate in Hz. The signal should be sampled at a rate appropriate for the analysis (typically 44100 Hz or 48000 Hz for audio). field_type : str, default="free" Type of sound field. Options: - 'free': Free field (sound arriving from a specific direction) - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

Attributes

name : str Operation name: "loudness_zwst" field_type : str The sound field type used for calculation

Examples

Calculate steady-state loudness for a signal:

import wandas as wd signal = wd.read_wav("fan_noise.wav") loudness = signal.loudness_zwst(field_type="free") print(f"Steady-state loudness: {loudness.data[0]:.2f} sones")

Notes
  • The output contains a single loudness value in sones for each channel
  • For mono signals, the loudness is calculated directly
  • For multi-channel signals, loudness is calculated per channel
  • The method follows ISO 532-1:2017 standard for steady-state loudness
  • Typical loudness values: 1 sone ≈ 40 phon (loudness level)
  • This method is suitable for stationary signals such as fan noise, constant machinery sounds, or other steady sounds
References

.. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness — Part 1: Zwicker method" .. [2] MoSQITo documentation: https://mosqito.readthedocs.io/en/latest/

Source code in wandas/processing/psychoacoustic.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
class LoudnessZwst(AudioOperation[NDArrayReal, NDArrayReal]):
    """
    Calculate steady-state loudness using Zwicker method (ISO 532-1:2017).

    This operation computes the loudness of stationary (steady) signals according to
    the Zwicker method, as specified in ISO 532-1:2017. It uses the MoSQITo library's
    implementation of the standardized loudness calculation for steady signals.

    The loudness is calculated in sones, a unit of perceived loudness where a doubling
    of sones corresponds to a doubling of perceived loudness.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate in Hz. The signal should be sampled at a rate appropriate
        for the analysis (typically 44100 Hz or 48000 Hz for audio).
    field_type : str, default="free"
        Type of sound field. Options:
        - 'free': Free field (sound arriving from a specific direction)
        - 'diffuse': Diffuse field (sound arriving uniformly from all directions)

    Attributes
    ----------
    name : str
        Operation name: "loudness_zwst"
    field_type : str
        The sound field type used for calculation

    Examples
    --------
    Calculate steady-state loudness for a signal:
    >>> import wandas as wd
    >>> signal = wd.read_wav("fan_noise.wav")
    >>> loudness = signal.loudness_zwst(field_type="free")
    >>> print(f"Steady-state loudness: {loudness.data[0]:.2f} sones")

    Notes
    -----
    - The output contains a single loudness value in sones for each channel
    - For mono signals, the loudness is calculated directly
    - For multi-channel signals, loudness is calculated per channel
    - The method follows ISO 532-1:2017 standard for steady-state loudness
    - Typical loudness values: 1 sone ≈ 40 phon (loudness level)
    - This method is suitable for stationary signals such as fan noise,
      constant machinery sounds, or other steady sounds

    References
    ----------
    .. [1] ISO 532-1:2017, "Acoustics — Methods for calculating loudness —
           Part 1: Zwicker method"
    .. [2] MoSQITo documentation:
           https://mosqito.readthedocs.io/en/latest/
    """

    name = "loudness_zwst"

    def __init__(self, sampling_rate: float, field_type: str = "free"):
        """
        Initialize steady-state loudness calculation operation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        field_type : str, default="free"
            Type of sound field ('free' or 'diffuse')
        """
        self.field_type = field_type
        super().__init__(sampling_rate, field_type=field_type)

    def validate_params(self) -> None:
        """
        Validate parameters.

        Raises
        ------
        ValueError
            If field_type is not 'free' or 'diffuse'
        """
        if self.field_type not in ("free", "diffuse"):
            raise ValueError(
                f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
            )

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Get metadata updates to apply after processing.

        For steady-state loudness, the output is a single value per channel,
        so no sampling rate update is needed (output is scalar, not time-series).

        Returns
        -------
        dict
            Empty dictionary (no metadata updates needed)

        Notes
        -----
        Unlike time-varying loudness, steady-state loudness produces a single
        value, not a time series, so the sampling rate concept doesn't apply.
        """
        return {}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation.

        The steady-state loudness calculation produces a single loudness value
        per channel.

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape: (channels, 1) - one loudness value per channel
        """
        n_channels = input_shape[0] if len(input_shape) > 1 else 1
        return (n_channels, 1)

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """
        Process array to calculate steady-state loudness.

        This method calculates the steady-state loudness for each channel
        of the input signal using the Zwicker method.

        Parameters
        ----------
        x : NDArrayReal
            Input signal array with shape (channels, samples) or (samples,)

        Returns
        -------
        NDArrayReal
            Steady-state loudness in sones for each channel.
            Shape: (channels, 1)

        Notes
        -----
        The function processes each channel independently and returns
        a single loudness value per channel.
        """
        logger.debug(
            f"Calculating steady-state loudness for signal with shape: {x.shape}, "
            f"field_type: {self.field_type}"
        )

        # Handle 1D input (single channel)
        if x.ndim == 1:
            x = x.reshape(1, -1)

        n_channels = x.shape[0]
        loudness_results = []

        for ch in range(n_channels):
            channel_data = x[ch, :]

            # Ensure channel_data is a contiguous 1D NumPy array
            channel_data = np.asarray(channel_data).ravel()

            # Call MoSQITo's loudness_zwst function
            # Returns: N (single loudness value), N_spec (specific loudness),
            #          bark_axis
            loudness_n, _, _ = loudness_zwst_mosqito(
                channel_data, self.sampling_rate, field_type=self.field_type
            )

            loudness_results.append(loudness_n)

            logger.debug(
                f"Channel {ch}: Calculated steady-state loudness: "
                f"{loudness_n:.2f} sones"
            )

        # Stack results and reshape to (channels, 1)
        result: NDArrayReal = np.array(loudness_results).reshape(n_channels, 1)

        logger.debug(
            f"Steady-state loudness calculation complete, output shape: {result.shape}"
        )
        return result
Attributes
name = 'loudness_zwst' class-attribute instance-attribute
field_type = field_type instance-attribute
Functions
__init__(sampling_rate, field_type='free')

Initialize steady-state loudness calculation operation.

Parameters

sampling_rate : float Sampling rate (Hz) field_type : str, default="free" Type of sound field ('free' or 'diffuse')

Source code in wandas/processing/psychoacoustic.py
277
278
279
280
281
282
283
284
285
286
287
288
289
def __init__(self, sampling_rate: float, field_type: str = "free"):
    """
    Initialize steady-state loudness calculation operation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    field_type : str, default="free"
        Type of sound field ('free' or 'diffuse')
    """
    self.field_type = field_type
    super().__init__(sampling_rate, field_type=field_type)
validate_params()

Validate parameters.

Raises

ValueError If field_type is not 'free' or 'diffuse'

Source code in wandas/processing/psychoacoustic.py
291
292
293
294
295
296
297
298
299
300
301
302
303
def validate_params(self) -> None:
    """
    Validate parameters.

    Raises
    ------
    ValueError
        If field_type is not 'free' or 'diffuse'
    """
    if self.field_type not in ("free", "diffuse"):
        raise ValueError(
            f"field_type must be 'free' or 'diffuse', got '{self.field_type}'"
        )
get_metadata_updates()

Get metadata updates to apply after processing.

For steady-state loudness, the output is a single value per channel, so no sampling rate update is needed (output is scalar, not time-series).

Returns

dict Empty dictionary (no metadata updates needed)

Notes

Unlike time-varying loudness, steady-state loudness produces a single value, not a time series, so the sampling rate concept doesn't apply.

Source code in wandas/processing/psychoacoustic.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Get metadata updates to apply after processing.

    For steady-state loudness, the output is a single value per channel,
    so no sampling rate update is needed (output is scalar, not time-series).

    Returns
    -------
    dict
        Empty dictionary (no metadata updates needed)

    Notes
    -----
    Unlike time-varying loudness, steady-state loudness produces a single
    value, not a time series, so the sampling rate concept doesn't apply.
    """
    return {}
calculate_output_shape(input_shape)

Calculate output data shape after operation.

The steady-state loudness calculation produces a single loudness value per channel.

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape: (channels, 1) - one loudness value per channel

Source code in wandas/processing/psychoacoustic.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation.

    The steady-state loudness calculation produces a single loudness value
    per channel.

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape: (channels, 1) - one loudness value per channel
    """
    n_channels = input_shape[0] if len(input_shape) > 1 else 1
    return (n_channels, 1)
RoughnessDw

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Calculate time-varying roughness using Daniel and Weber method.

This operation computes the roughness of audio signals according to the Daniel and Weber (1997) method. It uses the MoSQITo library's implementation of the standardized roughness calculation.

Roughness is a psychoacoustic metric that quantifies the perceived harshness or roughness of a sound. The unit is asper, where higher values indicate rougher sounds.

The calculation follows the standard formula: R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

Parameters

sampling_rate : float Sampling rate in Hz. The signal should be sampled at a rate appropriate for the analysis (typically 44100 Hz or 48000 Hz for audio). overlap : float, default=0.5 Overlapping coefficient for the analysis windows (0.0 to 1.0). The analysis uses 200ms windows: - overlap=0.5: 100ms hop size → ~10 Hz output sampling rate - overlap=0.0: 200ms hop size → ~5 Hz output sampling rate

Attributes

name : str Operation name: "roughness_dw" overlap : float The overlapping coefficient used for calculation

Examples

Calculate roughness for a signal:

import wandas as wd signal = wd.read_wav("motor_noise.wav") roughness = signal.roughness_dw(overlap=0.5) print(f"Mean roughness: {roughness.data.mean():.2f} asper")

Notes
  • The output contains time-varying roughness values in asper
  • For mono signals, the roughness is calculated directly
  • For multi-channel signals, roughness is calculated per channel
  • The method follows Daniel & Weber (1997) standard
  • Typical roughness values: 0-2 asper for most sounds
  • Higher overlap values provide better time resolution but increase computational cost
References

.. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness: Implementation of an optimized model." Acustica, 83, 113-123. .. [2] MoSQITo documentation: https://mosqito.readthedocs.io/en/latest/

Source code in wandas/processing/psychoacoustic.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
class RoughnessDw(AudioOperation[NDArrayReal, NDArrayReal]):
    """
    Calculate time-varying roughness using Daniel and Weber method.

    This operation computes the roughness of audio signals according to
    the Daniel and Weber (1997) method. It uses the MoSQITo library's
    implementation of the standardized roughness calculation.

    Roughness is a psychoacoustic metric that quantifies the perceived
    harshness or roughness of a sound. The unit is asper, where higher
    values indicate rougher sounds.

    The calculation follows the standard formula:
    R = 0.25 * sum(R'_i) for i=1 to 47 Bark bands

    Parameters
    ----------
    sampling_rate : float
        Sampling rate in Hz. The signal should be sampled at a rate appropriate
        for the analysis (typically 44100 Hz or 48000 Hz for audio).
    overlap : float, default=0.5
        Overlapping coefficient for the analysis windows (0.0 to 1.0).
        The analysis uses 200ms windows:
        - overlap=0.5: 100ms hop size → ~10 Hz output sampling rate
        - overlap=0.0: 200ms hop size → ~5 Hz output sampling rate

    Attributes
    ----------
    name : str
        Operation name: "roughness_dw"
    overlap : float
        The overlapping coefficient used for calculation

    Examples
    --------
    Calculate roughness for a signal:
    >>> import wandas as wd
    >>> signal = wd.read_wav("motor_noise.wav")
    >>> roughness = signal.roughness_dw(overlap=0.5)
    >>> print(f"Mean roughness: {roughness.data.mean():.2f} asper")

    Notes
    -----
    - The output contains time-varying roughness values in asper
    - For mono signals, the roughness is calculated directly
    - For multi-channel signals, roughness is calculated per channel
    - The method follows Daniel & Weber (1997) standard
    - Typical roughness values: 0-2 asper for most sounds
    - Higher overlap values provide better time resolution but increase
      computational cost

    References
    ----------
    .. [1] Daniel, P., & Weber, R. (1997). "Psychoacoustical roughness:
           Implementation of an optimized model." Acustica, 83, 113-123.
    .. [2] MoSQITo documentation:
           https://mosqito.readthedocs.io/en/latest/
    """

    name = "roughness_dw"

    def __init__(self, sampling_rate: float, overlap: float = 0.5) -> None:
        """
        Initialize Roughness calculation operation.

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        overlap : float, default=0.5
            Overlapping coefficient (0.0 to 1.0)
        """
        self.overlap = overlap
        super().__init__(sampling_rate, overlap=overlap)

    def validate_params(self) -> None:
        """
        Validate parameters.

        Raises
        ------
        ValueError
            If overlap is not in [0.0, 1.0]
        """
        if not 0.0 <= self.overlap <= 1.0:
            raise ValueError(f"overlap must be in [0.0, 1.0], got {self.overlap}")

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Update sampling rate based on overlap and window size.

        The Daniel & Weber method uses 200ms windows. The output
        sampling rate depends on the overlap:
        - overlap=0.0: hop=200ms → fs=5 Hz
        - overlap=0.5: hop=100ms → fs=10 Hz
        - overlap=0.75: hop=50ms → fs=20 Hz

        Returns
        -------
        dict
            Metadata updates with new sampling rate

        Notes
        -----
        The output sampling rate is approximately 1 / (0.2 * (1 - overlap)) Hz.
        """
        window_duration = 0.2  # 200ms window
        hop_duration = window_duration * (1 - self.overlap)
        output_sampling_rate = 1.0 / hop_duration if hop_duration > 0 else 5.0
        return {"sampling_rate": output_sampling_rate}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation.

        The roughness calculation produces a time-varying output where the
        number of time points depends on the signal length and overlap.

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels, time_samples)
        """
        n_channels = input_shape[0] if len(input_shape) > 1 else 1
        n_samples = input_shape[-1]

        # Estimate output length based on window size and overlap
        window_samples = int(0.2 * self.sampling_rate)  # 200ms
        hop_samples = int(window_samples * (1 - self.overlap))

        if hop_samples > 0:
            estimated_time_samples = max(
                1, (n_samples - window_samples) // hop_samples + 1
            )
        else:
            estimated_time_samples = 1

        return (n_channels, estimated_time_samples)

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """
        Process array to calculate roughness.

        This method calculates the time-varying roughness for each channel
        of the input signal using the Daniel and Weber method.

        Parameters
        ----------
        x : NDArrayReal
            Input signal array with shape (channels, samples) or (samples,)

        Returns
        -------
        NDArrayReal
            Time-varying roughness in asper for each channel.
            Shape: (channels, time_samples)

        Notes
        -----
        The function processes each channel independently and returns
        the total roughness values (R). The specific roughness per Bark
        band (R_spec) is not returned by this operation but can be obtained
        using the roughness_dw_spec method.
        """
        logger.debug(
            f"Calculating roughness for signal with shape: {x.shape}, "
            f"overlap: {self.overlap}"
        )

        # Handle 1D input (single channel)
        if x.ndim == 1:
            x = x.reshape(1, -1)

        n_channels = x.shape[0]
        roughness_results = []

        for ch in range(n_channels):
            channel_data = x[ch, :]

            # Ensure channel_data is a contiguous 1D NumPy array
            channel_data = np.asarray(channel_data).ravel()

            # Call MoSQITo's roughness_dw function
            # Returns: R (total roughness), R_spec (specific roughness),
            #          bark_axis, time_axis
            roughness_r, _, _, _ = roughness_dw_mosqito(
                channel_data, self.sampling_rate, overlap=self.overlap
            )

            roughness_results.append(roughness_r)

            logger.debug(
                f"Channel {ch}: Calculated roughness with "
                f"{len(roughness_r)} time points, "
                f"max roughness: {np.max(roughness_r):.2f} asper"
            )

        # Stack results
        result: NDArrayReal = np.stack(roughness_results, axis=0)

        logger.debug(f"Roughness calculation complete, output shape: {result.shape}")
        return result
Attributes
name = 'roughness_dw' class-attribute instance-attribute
overlap = overlap instance-attribute
Functions
__init__(sampling_rate, overlap=0.5)

Initialize Roughness calculation operation.

Parameters

sampling_rate : float Sampling rate (Hz) overlap : float, default=0.5 Overlapping coefficient (0.0 to 1.0)

Source code in wandas/processing/psychoacoustic.py
473
474
475
476
477
478
479
480
481
482
483
484
485
def __init__(self, sampling_rate: float, overlap: float = 0.5) -> None:
    """
    Initialize Roughness calculation operation.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    overlap : float, default=0.5
        Overlapping coefficient (0.0 to 1.0)
    """
    self.overlap = overlap
    super().__init__(sampling_rate, overlap=overlap)
validate_params()

Validate parameters.

Raises

ValueError If overlap is not in [0.0, 1.0]

Source code in wandas/processing/psychoacoustic.py
487
488
489
490
491
492
493
494
495
496
497
def validate_params(self) -> None:
    """
    Validate parameters.

    Raises
    ------
    ValueError
        If overlap is not in [0.0, 1.0]
    """
    if not 0.0 <= self.overlap <= 1.0:
        raise ValueError(f"overlap must be in [0.0, 1.0], got {self.overlap}")
get_metadata_updates()

Update sampling rate based on overlap and window size.

The Daniel & Weber method uses 200ms windows. The output sampling rate depends on the overlap: - overlap=0.0: hop=200ms → fs=5 Hz - overlap=0.5: hop=100ms → fs=10 Hz - overlap=0.75: hop=50ms → fs=20 Hz

Returns

dict Metadata updates with new sampling rate

Notes

The output sampling rate is approximately 1 / (0.2 * (1 - overlap)) Hz.

Source code in wandas/processing/psychoacoustic.py
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Update sampling rate based on overlap and window size.

    The Daniel & Weber method uses 200ms windows. The output
    sampling rate depends on the overlap:
    - overlap=0.0: hop=200ms → fs=5 Hz
    - overlap=0.5: hop=100ms → fs=10 Hz
    - overlap=0.75: hop=50ms → fs=20 Hz

    Returns
    -------
    dict
        Metadata updates with new sampling rate

    Notes
    -----
    The output sampling rate is approximately 1 / (0.2 * (1 - overlap)) Hz.
    """
    window_duration = 0.2  # 200ms window
    hop_duration = window_duration * (1 - self.overlap)
    output_sampling_rate = 1.0 / hop_duration if hop_duration > 0 else 5.0
    return {"sampling_rate": output_sampling_rate}
calculate_output_shape(input_shape)

Calculate output data shape after operation.

The roughness calculation produces a time-varying output where the number of time points depends on the signal length and overlap.

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels, time_samples)

Source code in wandas/processing/psychoacoustic.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation.

    The roughness calculation produces a time-varying output where the
    number of time points depends on the signal length and overlap.

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels, time_samples)
    """
    n_channels = input_shape[0] if len(input_shape) > 1 else 1
    n_samples = input_shape[-1]

    # Estimate output length based on window size and overlap
    window_samples = int(0.2 * self.sampling_rate)  # 200ms
    hop_samples = int(window_samples * (1 - self.overlap))

    if hop_samples > 0:
        estimated_time_samples = max(
            1, (n_samples - window_samples) // hop_samples + 1
        )
    else:
        estimated_time_samples = 1

    return (n_channels, estimated_time_samples)
RoughnessDwSpec

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Specific roughness (R_spec) operation.

Computes per-Bark-band specific roughness over time using MoSQITo's roughness_dw implementation. Output is band-by-time.

The bark_axis is retrieved dynamically from MoSQITo during initialization to ensure consistency with MoSQITo's implementation. Results are cached based on sampling_rate and overlap to avoid redundant computations.

Source code in wandas/processing/psychoacoustic.py
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
class RoughnessDwSpec(AudioOperation[NDArrayReal, NDArrayReal]):
    """Specific roughness (R_spec) operation.

    Computes per-Bark-band specific roughness over time using MoSQITo's
    `roughness_dw` implementation. Output is band-by-time.

    The bark_axis is retrieved dynamically from MoSQITo during initialization
    to ensure consistency with MoSQITo's implementation. Results are cached
    based on sampling_rate and overlap to avoid redundant computations.
    """

    name = "roughness_dw_spec"
    # Class-level cache: {(sampling_rate, overlap): bark_axis}
    _bark_axis_cache: dict[tuple[float, float], NDArrayReal] = {}

    def __init__(self, sampling_rate: float, overlap: float = 0.5) -> None:
        self.overlap = overlap
        self.validate_params()
        # Check cache first to avoid redundant MoSQITo calls
        cache_key = (sampling_rate, overlap)
        if cache_key in RoughnessDwSpec._bark_axis_cache:
            logger.debug(
                f"Using cached bark_axis for sampling_rate={sampling_rate}, "
                f"overlap={overlap}"
            )
            self._bark_axis: NDArrayReal = RoughnessDwSpec._bark_axis_cache[cache_key]
        else:
            # Retrieve bark_axis dynamically from MoSQITo to ensure consistency
            # Use a minimal reference signal to get the bark_axis structure
            logger.debug(
                f"Computing bark_axis from MoSQITo for sampling_rate={sampling_rate}, "
                f"overlap={overlap}"
            )
            reference_signal = np.zeros(
                int(sampling_rate * 0.2)
            )  # 200ms minimal signal
            try:
                _, _, bark_axis_from_mosqito, _ = roughness_dw_mosqito(
                    reference_signal, sampling_rate, overlap=overlap
                )
            except Exception as e:
                logger.error(
                    f"Failed to retrieve bark_axis from MoSQITo's roughness_dw: {e}"
                )
                raise RuntimeError(
                    "Could not initialize RoughnessDwSpec: error retrieving bark_axis "
                    "from MoSQITo."
                ) from e
            if bark_axis_from_mosqito is None or (
                hasattr(bark_axis_from_mosqito, "__len__")
                and len(bark_axis_from_mosqito) == 0
            ):
                logger.error(
                    "MoSQITo's roughness_dw returned an empty or None bark_axis."
                )
                raise RuntimeError(
                    "Could not initialize RoughnessDwSpec: MoSQITo's roughness_dw "
                    "returned an empty or None bark_axis."
                )
            self._bark_axis = bark_axis_from_mosqito
            # Cache the result for future use
            RoughnessDwSpec._bark_axis_cache[cache_key] = bark_axis_from_mosqito
        super().__init__(sampling_rate, overlap=overlap)

    @property
    def bark_axis(self) -> NDArrayReal:
        return self._bark_axis

    def validate_params(self) -> None:
        if not 0.0 <= self.overlap <= 1.0:
            raise ValueError(f"overlap must be in [0.0, 1.0], got {self.overlap}")

    def get_metadata_updates(self) -> dict[str, Any]:
        window_duration = 0.2
        hop_duration = window_duration * (1 - self.overlap)
        output_sampling_rate = 1.0 / hop_duration if hop_duration > 0 else 5.0

        return {"sampling_rate": output_sampling_rate, "bark_axis": self._bark_axis}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        n_bark_bands = len(self._bark_axis)
        if len(input_shape) == 1:
            n_samples = input_shape[0]
            n_channels = 1
        else:
            n_channels, n_samples = input_shape[:2]

        window_samples = int(0.2 * self.sampling_rate)
        hop_samples = int(window_samples * (1 - self.overlap))

        if hop_samples > 0:
            estimated_time_samples = max(
                1, (n_samples - window_samples) // hop_samples + 1
            )
        else:
            estimated_time_samples = 1

        if n_channels == 1:
            return (n_bark_bands, estimated_time_samples)
        return (n_channels, n_bark_bands, estimated_time_samples)

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        logger.debug(
            "Calculating specific roughness for signal with shape: %s, overlap: %s",
            x.shape,
            self.overlap,
        )

        # Ensure (n_channels, n_samples)
        if x.ndim == 1:
            x_proc: NDArrayReal = x.reshape(1, -1)
        else:
            x_proc = x

        n_channels = x_proc.shape[0]
        r_spec_list: list[NDArrayReal] = []

        for ch in range(n_channels):
            channel_data = np.asarray(x_proc[ch]).ravel()

            # Call MoSQITo's roughness_dw (module-level import)
            _, r_spec, bark_axis, _ = roughness_dw_mosqito(
                channel_data, self.sampling_rate, overlap=self.overlap
            )

            r_spec_list.append(r_spec)
            if self._bark_axis is None:
                self._bark_axis = bark_axis

            logger.debug(
                "Channel %d: calculated specific roughness shape=%s",
                ch,
                r_spec.shape,
            )

        if n_channels == 1:
            result: NDArrayReal = r_spec_list[0]
            return result
        return np.stack(r_spec_list, axis=0)
Attributes
name = 'roughness_dw_spec' class-attribute instance-attribute
overlap = overlap instance-attribute
bark_axis property
Functions
__init__(sampling_rate, overlap=0.5)
Source code in wandas/processing/psychoacoustic.py
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
def __init__(self, sampling_rate: float, overlap: float = 0.5) -> None:
    self.overlap = overlap
    self.validate_params()
    # Check cache first to avoid redundant MoSQITo calls
    cache_key = (sampling_rate, overlap)
    if cache_key in RoughnessDwSpec._bark_axis_cache:
        logger.debug(
            f"Using cached bark_axis for sampling_rate={sampling_rate}, "
            f"overlap={overlap}"
        )
        self._bark_axis: NDArrayReal = RoughnessDwSpec._bark_axis_cache[cache_key]
    else:
        # Retrieve bark_axis dynamically from MoSQITo to ensure consistency
        # Use a minimal reference signal to get the bark_axis structure
        logger.debug(
            f"Computing bark_axis from MoSQITo for sampling_rate={sampling_rate}, "
            f"overlap={overlap}"
        )
        reference_signal = np.zeros(
            int(sampling_rate * 0.2)
        )  # 200ms minimal signal
        try:
            _, _, bark_axis_from_mosqito, _ = roughness_dw_mosqito(
                reference_signal, sampling_rate, overlap=overlap
            )
        except Exception as e:
            logger.error(
                f"Failed to retrieve bark_axis from MoSQITo's roughness_dw: {e}"
            )
            raise RuntimeError(
                "Could not initialize RoughnessDwSpec: error retrieving bark_axis "
                "from MoSQITo."
            ) from e
        if bark_axis_from_mosqito is None or (
            hasattr(bark_axis_from_mosqito, "__len__")
            and len(bark_axis_from_mosqito) == 0
        ):
            logger.error(
                "MoSQITo's roughness_dw returned an empty or None bark_axis."
            )
            raise RuntimeError(
                "Could not initialize RoughnessDwSpec: MoSQITo's roughness_dw "
                "returned an empty or None bark_axis."
            )
        self._bark_axis = bark_axis_from_mosqito
        # Cache the result for future use
        RoughnessDwSpec._bark_axis_cache[cache_key] = bark_axis_from_mosqito
    super().__init__(sampling_rate, overlap=overlap)
validate_params()
Source code in wandas/processing/psychoacoustic.py
693
694
695
def validate_params(self) -> None:
    if not 0.0 <= self.overlap <= 1.0:
        raise ValueError(f"overlap must be in [0.0, 1.0], got {self.overlap}")
get_metadata_updates()
Source code in wandas/processing/psychoacoustic.py
697
698
699
700
701
702
def get_metadata_updates(self) -> dict[str, Any]:
    window_duration = 0.2
    hop_duration = window_duration * (1 - self.overlap)
    output_sampling_rate = 1.0 / hop_duration if hop_duration > 0 else 5.0

    return {"sampling_rate": output_sampling_rate, "bark_axis": self._bark_axis}
calculate_output_shape(input_shape)
Source code in wandas/processing/psychoacoustic.py
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    n_bark_bands = len(self._bark_axis)
    if len(input_shape) == 1:
        n_samples = input_shape[0]
        n_channels = 1
    else:
        n_channels, n_samples = input_shape[:2]

    window_samples = int(0.2 * self.sampling_rate)
    hop_samples = int(window_samples * (1 - self.overlap))

    if hop_samples > 0:
        estimated_time_samples = max(
            1, (n_samples - window_samples) // hop_samples + 1
        )
    else:
        estimated_time_samples = 1

    if n_channels == 1:
        return (n_bark_bands, estimated_time_samples)
    return (n_channels, n_bark_bands, estimated_time_samples)
Functions

spectral

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
FFT

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

FFT (Fast Fourier Transform) operation

Source code in wandas/processing/spectral.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class FFT(AudioOperation[NDArrayReal, NDArrayComplex]):
    """FFT (Fast Fourier Transform) operation"""

    name = "fft"
    n_fft: int | None
    window: str

    def __init__(
        self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
    ):
        """
        Initialize FFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int, optional
            FFT size, default is None (determined by input size)
        window : str, optional
            Window function type, default is 'hann'

        Raises
        ------
        ValueError
            If n_fft is not a positive integer
        """
        # Validate n_fft parameter
        if n_fft is not None and n_fft <= 0:
            raise ValueError(
                f"Invalid FFT size\n"
                f"  Got: {n_fft}\n"
                f"  Expected: Positive integer > 0\n"
                f"FFT size must be a positive integer.\n"
                f"Common values: 512, 1024, 2048, 4096,\n"
                f"8192 (powers of 2 are most efficient)"
            )

        self.n_fft = n_fft
        self.window = window
        super().__init__(sampling_rate, n_fft=n_fft, window=window)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        操作後の出力データの形状を計算します

        Parameters
        ----------
        input_shape : tuple
            入力データの形状 (channels, samples)

        Returns
        -------
        tuple
            出力データの形状 (channels, freqs)
        """
        n_freqs = self.n_fft // 2 + 1 if self.n_fft else input_shape[-1] // 2 + 1
        return (*input_shape[:-1], n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "FFT"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """FFT操作のプロセッサ関数を作成"""
        from scipy.signal import get_window

        if self.n_fft is not None and x.shape[-1] > self.n_fft:
            # If n_fft is specified and input length exceeds it, truncate
            x = x[..., : self.n_fft]

        win = get_window(self.window, x.shape[-1])
        x = x * win
        result: NDArrayComplex = np.fft.rfft(x, n=self.n_fft, axis=-1)
        result[..., 1:-1] *= 2.0
        # 窓関数補正
        scaling_factor = np.sum(win)
        result = result / scaling_factor
        return result
Attributes
name = 'fft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
window = window instance-attribute
Functions
__init__(sampling_rate, n_fft=None, window='hann')

Initialize FFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int, optional FFT size, default is None (determined by input size) window : str, optional Window function type, default is 'hann'

Raises

ValueError If n_fft is not a positive integer

Source code in wandas/processing/spectral.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def __init__(
    self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
):
    """
    Initialize FFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int, optional
        FFT size, default is None (determined by input size)
    window : str, optional
        Window function type, default is 'hann'

    Raises
    ------
    ValueError
        If n_fft is not a positive integer
    """
    # Validate n_fft parameter
    if n_fft is not None and n_fft <= 0:
        raise ValueError(
            f"Invalid FFT size\n"
            f"  Got: {n_fft}\n"
            f"  Expected: Positive integer > 0\n"
            f"FFT size must be a positive integer.\n"
            f"Common values: 512, 1024, 2048, 4096,\n"
            f"8192 (powers of 2 are most efficient)"
        )

    self.n_fft = n_fft
    self.window = window
    super().__init__(sampling_rate, n_fft=n_fft, window=window)
calculate_output_shape(input_shape)

操作後の出力データの形状を計算します

Parameters

input_shape : tuple 入力データの形状 (channels, samples)

Returns

tuple 出力データの形状 (channels, freqs)

Source code in wandas/processing/spectral.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    操作後の出力データの形状を計算します

    Parameters
    ----------
    input_shape : tuple
        入力データの形状 (channels, samples)

    Returns
    -------
    tuple
        出力データの形状 (channels, freqs)
    """
    n_freqs = self.n_fft // 2 + 1 if self.n_fft else input_shape[-1] // 2 + 1
    return (*input_shape[:-1], n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
175
176
177
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "FFT"
IFFT

Bases: AudioOperation[NDArrayComplex, NDArrayReal]

IFFT (Inverse Fast Fourier Transform) operation

Source code in wandas/processing/spectral.py
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
class IFFT(AudioOperation[NDArrayComplex, NDArrayReal]):
    """IFFT (Inverse Fast Fourier Transform) operation"""

    name = "ifft"
    n_fft: int | None
    window: str

    def __init__(
        self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
    ):
        """
        Initialize IFFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : Optional[int], optional
            IFFT size, default is None (determined based on input size)
        window : str, optional
            Window function type, default is 'hann'
        """
        self.n_fft = n_fft
        self.window = window
        super().__init__(sampling_rate, n_fft=n_fft, window=window)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, freqs)

        Returns
        -------
        tuple
            Output data shape (channels, samples)
        """
        n_samples = 2 * (input_shape[-1] - 1) if self.n_fft is None else self.n_fft
        return (*input_shape[:-1], n_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "iFFT"

    def _process_array(self, x: NDArrayComplex) -> NDArrayReal:
        """Create processor function for IFFT operation"""
        logger.debug(f"Applying IFFT to array with shape: {x.shape}")

        # Restore frequency component scaling (remove the 2.0 multiplier applied in FFT)
        _x = x.copy()
        _x[..., 1:-1] /= 2.0

        # Execute IFFT
        result: NDArrayReal = np.fft.irfft(_x, n=self.n_fft, axis=-1)

        # Window function correction (inverse of FFT operation)
        from scipy.signal import get_window

        win = get_window(self.window, result.shape[-1])

        # Correct the FFT window function scaling
        scaling_factor = np.sum(win) / result.shape[-1]
        result = result / scaling_factor

        logger.debug(f"IFFT applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'ifft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
window = window instance-attribute
Functions
__init__(sampling_rate, n_fft=None, window='hann')

Initialize IFFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : Optional[int], optional IFFT size, default is None (determined based on input size) window : str, optional Window function type, default is 'hann'

Source code in wandas/processing/spectral.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def __init__(
    self, sampling_rate: float, n_fft: int | None = None, window: str = "hann"
):
    """
    Initialize IFFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : Optional[int], optional
        IFFT size, default is None (determined based on input size)
    window : str, optional
        Window function type, default is 'hann'
    """
    self.n_fft = n_fft
    self.window = window
    super().__init__(sampling_rate, n_fft=n_fft, window=window)
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, freqs)

Returns

tuple Output data shape (channels, samples)

Source code in wandas/processing/spectral.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, freqs)

    Returns
    -------
    tuple
        Output data shape (channels, samples)
    """
    n_samples = 2 * (input_shape[-1] - 1) if self.n_fft is None else self.n_fft
    return (*input_shape[:-1], n_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
240
241
242
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "iFFT"
STFT

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

Short-Time Fourier Transform operation

Source code in wandas/processing/spectral.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
class STFT(AudioOperation[NDArrayReal, NDArrayComplex]):
    """Short-Time Fourier Transform operation"""

    name = "stft"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
    ):
        """
        Initialize STFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window type, default is 'hann'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "STFT"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.noverlap = (
            self.win_length - self.hop_length if hop_length is not None else None
        )
        self.window = window

        self.SFT = ShortTimeFFT(
            win=get_window(window, self.win_length),
            hop=self.hop_length,
            fs=sampling_rate,
            mfft=self.n_fft,
            scale_to="magnitude",
        )
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            win_length=self.win_length,
            hop_length=self.hop_length,
            window=window,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        n_samples = input_shape[-1]
        n_f = len(self.SFT.f)
        n_t = len(self.SFT.t(n_samples))
        return (input_shape[0], n_f, n_t)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "STFT"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """Apply SciPy STFT processing to multiple channels at once"""
        logger.debug(f"Applying SciPy STFT to array with shape: {x.shape}")

        # Convert 1D input to 2D
        if x.ndim == 1:
            x = x.reshape(1, -1)

        # Apply STFT to all channels at once
        result: NDArrayComplex = self.SFT.stft(x)
        result[..., 1:-1, :] *= 2.0
        logger.debug(f"SciPy STFT applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'stft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
noverlap = self.win_length - self.hop_length if hop_length is not None else None instance-attribute
window = window instance-attribute
SFT = ShortTimeFFT(win=(get_window(window, self.win_length)), hop=(self.hop_length), fs=sampling_rate, mfft=(self.n_fft), scale_to='magnitude') instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann')

Initialize STFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window type, default is 'hann'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
):
    """
    Initialize STFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window type, default is 'hann'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "STFT"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.noverlap = (
        self.win_length - self.hop_length if hop_length is not None else None
    )
    self.window = window

    self.SFT = ShortTimeFFT(
        win=get_window(window, self.win_length),
        hop=self.hop_length,
        fs=sampling_rate,
        mfft=self.n_fft,
        scale_to="magnitude",
    )
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        win_length=self.win_length,
        hop_length=self.hop_length,
        window=window,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/spectral.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    n_samples = input_shape[-1]
    n_f = len(self.SFT.f)
    n_t = len(self.SFT.t(n_samples))
    return (input_shape[0], n_f, n_t)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
349
350
351
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "STFT"
ISTFT

Bases: AudioOperation[NDArrayComplex, NDArrayReal]

Inverse Short-Time Fourier Transform operation

Source code in wandas/processing/spectral.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
class ISTFT(AudioOperation[NDArrayComplex, NDArrayReal]):
    """Inverse Short-Time Fourier Transform operation"""

    name = "istft"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        length: int | None = None,
    ):
        """
        Initialize ISTFT operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window type, default is 'hann'
        length : int, optional
            Length of output signal. Default is None (determined from input)

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "ISTFT"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.length = length

        # Instantiate ShortTimeFFT for ISTFT calculation
        self.SFT = ShortTimeFFT(
            win=get_window(window, self.win_length),
            hop=self.hop_length,
            fs=sampling_rate,
            mfft=self.n_fft,
            scale_to="magnitude",  # Consistent scaling with STFT
        )

        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            win_length=self.win_length,
            hop_length=self.hop_length,
            window=window,
            length=length,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after ISTFT operation.

        Uses the SciPy ShortTimeFFT calculation formula to compute the expected
        output length based on the input spectrogram dimensions and output range
        parameters (k0, k1).

        Parameters
        ----------
        input_shape : tuple
            Input spectrogram shape (channels, n_freqs, n_frames)
            where n_freqs = n_fft // 2 + 1 and n_frames is the number of time frames.

        Returns
        -------
        tuple
            Output shape (channels, output_samples) where output_samples is the
            reconstructed signal length determined by the output range [k0, k1).

        Notes
        -----
        The calculation follows SciPy's ShortTimeFFT.istft() implementation.
        When k1 is None (default), the maximum reconstructible signal length is
        computed as:

        .. math::

            q_{max} = n_{frames} + p_{min}

            k_{max} = (q_{max} - 1) \\cdot hop + m_{num} - m_{num\\_mid}

        The output length is then:

        .. math::

            output\\_samples = k_1 - k_0

        where k0 defaults to 0 and k1 defaults to k_max.

        Parameters that affect the calculation:
        - n_frames: number of time frames in the STFT
        - p_min: minimum frame index (ShortTimeFFT property)
        - hop: hop length (samples between frames)
        - m_num: window length
        - m_num_mid: window midpoint position
        - self.length: optional length override (if set, limits output)

        References
        ----------
        - SciPy ShortTimeFFT.istft:
          https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.istft.html
        - SciPy Source: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
        """
        n_channels = input_shape[0]
        n_frames = input_shape[-1]  # time_frames

        # SciPy ShortTimeFFT の計算式に従う
        # See: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
        q_max = n_frames + self.SFT.p_min
        k_max = (q_max - 1) * self.SFT.hop + self.SFT.m_num - self.SFT.m_num_mid

        # Default parameters: k0=0, k1=None (which becomes k_max)
        # The output length is k1 - k0 = k_max - 0 = k_max
        k0 = 0
        k1 = k_max

        # If self.length is specified, it acts as an override to limit the output
        if self.length is not None:
            k1 = min(self.length, k1)

        output_samples = k1 - k0

        return (n_channels, output_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "iSTFT"

    def _process_array(self, x: NDArrayComplex) -> NDArrayReal:
        """
        Apply SciPy ISTFT processing to multiple channels at once using ShortTimeFFT"""
        logger.debug(
            f"Applying SciPy ISTFT (ShortTimeFFT) to array with shape: {x.shape}"
        )

        # Convert 2D input to 3D (assume single channel)
        if x.ndim == 2:
            x = x.reshape(1, *x.shape)

        # Adjust scaling back if STFT applied factor of 2
        _x = np.copy(x)
        _x[..., 1:-1, :] /= 2.0

        # Apply ISTFT using the ShortTimeFFT instance
        result: NDArrayReal = self.SFT.istft(_x)

        # Trim to desired length if specified
        if self.length is not None:
            result = result[..., : self.length]

        logger.debug(
            f"ShortTimeFFT applied, returning result with shape: {result.shape}"
        )
        return result
Attributes
name = 'istft' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
length = length instance-attribute
SFT = ShortTimeFFT(win=(get_window(window, self.win_length)), hop=(self.hop_length), fs=sampling_rate, mfft=(self.n_fft), scale_to='magnitude') instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', length=None)

Initialize ISTFT operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window type, default is 'hann' length : int, optional Length of output signal. Default is None (determined from input)

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    length: int | None = None,
):
    """
    Initialize ISTFT operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window type, default is 'hann'
    length : int, optional
        Length of output signal. Default is None (determined from input)

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "ISTFT"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.length = length

    # Instantiate ShortTimeFFT for ISTFT calculation
    self.SFT = ShortTimeFFT(
        win=get_window(window, self.win_length),
        hop=self.hop_length,
        fs=sampling_rate,
        mfft=self.n_fft,
        scale_to="magnitude",  # Consistent scaling with STFT
    )

    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        win_length=self.win_length,
        hop_length=self.hop_length,
        window=window,
        length=length,
    )
calculate_output_shape(input_shape)

Calculate output data shape after ISTFT operation.

Uses the SciPy ShortTimeFFT calculation formula to compute the expected output length based on the input spectrogram dimensions and output range parameters (k0, k1).

Parameters

input_shape : tuple Input spectrogram shape (channels, n_freqs, n_frames) where n_freqs = n_fft // 2 + 1 and n_frames is the number of time frames.

Returns

tuple Output shape (channels, output_samples) where output_samples is the reconstructed signal length determined by the output range [k0, k1).

Notes

The calculation follows SciPy's ShortTimeFFT.istft() implementation. When k1 is None (default), the maximum reconstructible signal length is computed as:

.. math::

q_{max} = n_{frames} + p_{min}

k_{max} = (q_{max} - 1) \cdot hop + m_{num} - m_{num\_mid}

The output length is then:

.. math::

output\_samples = k_1 - k_0

where k0 defaults to 0 and k1 defaults to k_max.

Parameters that affect the calculation: - n_frames: number of time frames in the STFT - p_min: minimum frame index (ShortTimeFFT property) - hop: hop length (samples between frames) - m_num: window length - m_num_mid: window midpoint position - self.length: optional length override (if set, limits output)

References
  • SciPy ShortTimeFFT.istft: https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.istft.html
  • SciPy Source: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
Source code in wandas/processing/spectral.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after ISTFT operation.

    Uses the SciPy ShortTimeFFT calculation formula to compute the expected
    output length based on the input spectrogram dimensions and output range
    parameters (k0, k1).

    Parameters
    ----------
    input_shape : tuple
        Input spectrogram shape (channels, n_freqs, n_frames)
        where n_freqs = n_fft // 2 + 1 and n_frames is the number of time frames.

    Returns
    -------
    tuple
        Output shape (channels, output_samples) where output_samples is the
        reconstructed signal length determined by the output range [k0, k1).

    Notes
    -----
    The calculation follows SciPy's ShortTimeFFT.istft() implementation.
    When k1 is None (default), the maximum reconstructible signal length is
    computed as:

    .. math::

        q_{max} = n_{frames} + p_{min}

        k_{max} = (q_{max} - 1) \\cdot hop + m_{num} - m_{num\\_mid}

    The output length is then:

    .. math::

        output\\_samples = k_1 - k_0

    where k0 defaults to 0 and k1 defaults to k_max.

    Parameters that affect the calculation:
    - n_frames: number of time frames in the STFT
    - p_min: minimum frame index (ShortTimeFFT property)
    - hop: hop length (samples between frames)
    - m_num: window length
    - m_num_mid: window midpoint position
    - self.length: optional length override (if set, limits output)

    References
    ----------
    - SciPy ShortTimeFFT.istft:
      https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.ShortTimeFFT.istft.html
    - SciPy Source: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
    """
    n_channels = input_shape[0]
    n_frames = input_shape[-1]  # time_frames

    # SciPy ShortTimeFFT の計算式に従う
    # See: https://github.com/scipy/scipy/blob/main/scipy/signal/_short_time_fft.py
    q_max = n_frames + self.SFT.p_min
    k_max = (q_max - 1) * self.SFT.hop + self.SFT.m_num - self.SFT.m_num_mid

    # Default parameters: k0=0, k1=None (which becomes k_max)
    # The output length is k1 - k0 = k_max - 0 = k_max
    k0 = 0
    k1 = k_max

    # If self.length is specified, it acts as an override to limit the output
    if self.length is not None:
        k1 = min(self.length, k1)

    output_samples = k1 - k0

    return (n_channels, output_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
509
510
511
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "iSTFT"
Welch

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Welch

Source code in wandas/processing/spectral.py
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
class Welch(AudioOperation[NDArrayReal, NDArrayReal]):
    """Welch"""

    name = "welch"
    n_fft: int
    window: str
    hop_length: int | None
    win_length: int | None
    average: str
    detrend: str

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        average: str = "mean",
        detrend: str = "constant",
    ):
        """
        Initialize Welch operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int, optional
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str, optional
            Window function type, default is 'hann'
        average : str, optional
            Averaging method, default is 'mean'
        detrend : str, optional
            Detrend method, default is 'constant'

        Raises
        ------
        ValueError
            If n_fft, win_length, or hop_length are invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "Welch method"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.noverlap = (
            self.win_length - self.hop_length if hop_length is not None else None
        )
        self.window = window
        self.average = average
        self.detrend = detrend
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            win_length=self.win_length,
            hop_length=self.hop_length,
            window=window,
            average=average,
            detrend=detrend,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels, freqs)
        """
        n_freqs = self.n_fft // 2 + 1
        return (*input_shape[:-1], n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "PS"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for Welch operation"""
        from scipy import signal as ss

        _, result = ss.welch(
            x,
            nperseg=self.win_length,
            noverlap=self.noverlap,
            nfft=self.n_fft,
            window=self.window,
            average=self.average,
            detrend=self.detrend,
            scaling="spectrum",
        )

        if not isinstance(x, np.ndarray):
            # Trigger computation for Dask array
            raise ValueError(
                "Welch operation requires a Dask array, but received a non-ndarray."
            )
        return np.array(result)
Attributes
name = 'welch' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
noverlap = self.win_length - self.hop_length if hop_length is not None else None instance-attribute
window = window instance-attribute
average = average instance-attribute
detrend = detrend instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', average='mean', detrend='constant')

Initialize Welch operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int, optional FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str, optional Window function type, default is 'hann' average : str, optional Averaging method, default is 'mean' detrend : str, optional Detrend method, default is 'constant'

Raises

ValueError If n_fft, win_length, or hop_length are invalid

Source code in wandas/processing/spectral.py
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    average: str = "mean",
    detrend: str = "constant",
):
    """
    Initialize Welch operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int, optional
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str, optional
        Window function type, default is 'hann'
    average : str, optional
        Averaging method, default is 'mean'
    detrend : str, optional
        Detrend method, default is 'constant'

    Raises
    ------
    ValueError
        If n_fft, win_length, or hop_length are invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "Welch method"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.noverlap = (
        self.win_length - self.hop_length if hop_length is not None else None
    )
    self.window = window
    self.average = average
    self.detrend = detrend
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        win_length=self.win_length,
        hop_length=self.hop_length,
        window=window,
        average=average,
        detrend=detrend,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels, freqs)

Source code in wandas/processing/spectral.py
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels, freqs)
    """
    n_freqs = self.n_fft // 2 + 1
    return (*input_shape[:-1], n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
628
629
630
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "PS"
NOctSpectrum

Bases: AudioOperation[NDArrayReal, NDArrayReal]

N-octave spectrum operation

Source code in wandas/processing/spectral.py
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
class NOctSpectrum(AudioOperation[NDArrayReal, NDArrayReal]):
    """N-octave spectrum operation"""

    name = "noct_spectrum"

    def __init__(
        self,
        sampling_rate: float,
        fmin: float,
        fmax: float,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ):
        """
        Initialize N-octave spectrum

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        fmin : float
            Minimum frequency (Hz)
        fmax : float
            Maximum frequency (Hz)
        n : int, optional
            Number of octave divisions, default is 3
        G : int, optional
            Reference level, default is 10
        fr : int, optional
            Reference frequency, default is 1000
        """
        super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)
        self.fmin = fmin
        self.fmax = fmax
        self.n = n
        self.G = G
        self.fr = fr

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate output shape for octave spectrum
        _, fpref = _center_freq(
            fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
        )
        return (input_shape[0], fpref.shape[0])

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Oct"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for octave spectrum"""
        logger.debug(f"Applying NoctSpectrum to array with shape: {x.shape}")
        spec, _ = noct_spectrum(
            sig=x.T,
            fs=self.sampling_rate,
            fmin=self.fmin,
            fmax=self.fmax,
            n=self.n,
            G=self.G,
            fr=self.fr,
        )
        if spec.ndim == 1:
            # Add channel dimension for 1D
            spec = np.expand_dims(spec, axis=0)
        else:
            spec = spec.T
        logger.debug(f"NoctSpectrum applied, returning result with shape: {spec.shape}")
        return np.array(spec)
Attributes
name = 'noct_spectrum' class-attribute instance-attribute
fmin = fmin instance-attribute
fmax = fmax instance-attribute
n = n instance-attribute
G = G instance-attribute
fr = fr instance-attribute
Functions
__init__(sampling_rate, fmin, fmax, n=3, G=10, fr=1000)

Initialize N-octave spectrum

Parameters

sampling_rate : float Sampling rate (Hz) fmin : float Minimum frequency (Hz) fmax : float Maximum frequency (Hz) n : int, optional Number of octave divisions, default is 3 G : int, optional Reference level, default is 10 fr : int, optional Reference frequency, default is 1000

Source code in wandas/processing/spectral.py
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
def __init__(
    self,
    sampling_rate: float,
    fmin: float,
    fmax: float,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
):
    """
    Initialize N-octave spectrum

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    fmin : float
        Minimum frequency (Hz)
    fmax : float
        Maximum frequency (Hz)
    n : int, optional
        Number of octave divisions, default is 3
    G : int, optional
        Reference level, default is 10
    fr : int, optional
        Reference frequency, default is 1000
    """
    super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)
    self.fmin = fmin
    self.fmax = fmax
    self.n = n
    self.G = G
    self.fr = fr
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/spectral.py
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate output shape for octave spectrum
    _, fpref = _center_freq(
        fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
    )
    return (input_shape[0], fpref.shape[0])
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
714
715
716
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Oct"
NOctSynthesis

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Octave synthesis operation

Source code in wandas/processing/spectral.py
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
class NOctSynthesis(AudioOperation[NDArrayReal, NDArrayReal]):
    """Octave synthesis operation"""

    name = "noct_synthesis"

    def __init__(
        self,
        sampling_rate: float,
        fmin: float,
        fmax: float,
        n: int = 3,
        G: int = 10,  # noqa: N803
        fr: int = 1000,
    ):
        """
        Initialize octave synthesis

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        fmin : float
            Minimum frequency (Hz)
        fmax : float
            Maximum frequency (Hz)
        n : int, optional
            Number of octave divisions, default is 3
        G : int, optional
            Reference level, default is 10
        fr : int, optional
            Reference frequency, default is 1000
        """
        super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)

        self.fmin = fmin
        self.fmax = fmax
        self.n = n
        self.G = G
        self.fr = fr

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate output shape for octave spectrum
        _, fpref = _center_freq(
            fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
        )
        return (input_shape[0], fpref.shape[0])

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Octs"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for octave synthesis"""
        logger.debug(f"Applying NoctSynthesis to array with shape: {x.shape}")
        # Calculate n from shape[-1]
        n = x.shape[-1]  # Calculate n from shape[-1]
        if n % 2 == 0:
            n = n * 2 - 1
        else:
            n = (n - 1) * 2
        freqs = np.fft.rfftfreq(n, d=1 / self.sampling_rate)
        result, _ = noct_synthesis(
            spectrum=np.abs(x).T,
            freqs=freqs,
            fmin=self.fmin,
            fmax=self.fmax,
            n=self.n,
            G=self.G,
            fr=self.fr,
        )
        result = result.T
        logger.debug(
            f"NoctSynthesis applied, returning result with shape: {result.shape}"
        )
        return np.array(result)
Attributes
name = 'noct_synthesis' class-attribute instance-attribute
fmin = fmin instance-attribute
fmax = fmax instance-attribute
n = n instance-attribute
G = G instance-attribute
fr = fr instance-attribute
Functions
__init__(sampling_rate, fmin, fmax, n=3, G=10, fr=1000)

Initialize octave synthesis

Parameters

sampling_rate : float Sampling rate (Hz) fmin : float Minimum frequency (Hz) fmax : float Maximum frequency (Hz) n : int, optional Number of octave divisions, default is 3 G : int, optional Reference level, default is 10 fr : int, optional Reference frequency, default is 1000

Source code in wandas/processing/spectral.py
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
def __init__(
    self,
    sampling_rate: float,
    fmin: float,
    fmax: float,
    n: int = 3,
    G: int = 10,  # noqa: N803
    fr: int = 1000,
):
    """
    Initialize octave synthesis

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    fmin : float
        Minimum frequency (Hz)
    fmax : float
        Maximum frequency (Hz)
    n : int, optional
        Number of octave divisions, default is 3
    G : int, optional
        Reference level, default is 10
    fr : int, optional
        Reference frequency, default is 1000
    """
    super().__init__(sampling_rate, fmin=fmin, fmax=fmax, n=n, G=G, fr=fr)

    self.fmin = fmin
    self.fmax = fmax
    self.n = n
    self.G = G
    self.fr = fr
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/spectral.py
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate output shape for octave spectrum
    _, fpref = _center_freq(
        fmin=self.fmin, fmax=self.fmax, n=self.n, G=self.G, fr=self.fr
    )
    return (input_shape[0], fpref.shape[0])
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
799
800
801
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Octs"
Coherence

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Coherence estimation operation

Source code in wandas/processing/spectral.py
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
class Coherence(AudioOperation[NDArrayReal, NDArrayReal]):
    """Coherence estimation operation"""

    name = "coherence"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
    ):
        """
        Initialize coherence estimation operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window function, default is 'hann'
        detrend : str
            Type of detrend, default is 'constant'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "Coherence"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.detrend = detrend
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=window,
            detrend=detrend,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels * channels, freqs)
        """
        n_channels = input_shape[0]
        n_freqs = self.n_fft // 2 + 1
        return (n_channels * n_channels, n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "Coh"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Processor function for coherence estimation operation"""
        logger.debug(f"Applying coherence estimation to array with shape: {x.shape}")
        from scipy import signal as ss

        _, coh = ss.coherence(
            x=x[:, np.newaxis],
            y=x[np.newaxis, :],
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
        )

        # Reshape result to (n_channels * n_channels, n_freqs)
        result: NDArrayReal = coh.transpose(1, 0, 2).reshape(-1, coh.shape[-1])

        logger.debug(f"Coherence estimation applied, result shape: {result.shape}")
        return result
Attributes
name = 'coherence' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
detrend = detrend instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant')

Initialize coherence estimation operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window function, default is 'hann' detrend : str Type of detrend, default is 'constant'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
):
    """
    Initialize coherence estimation operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window function, default is 'hann'
    detrend : str
        Type of detrend, default is 'constant'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "Coherence"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.detrend = detrend
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=window,
        detrend=detrend,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels * channels, freqs)

Source code in wandas/processing/spectral.py
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels * channels, freqs)
    """
    n_channels = input_shape[0]
    n_freqs = self.n_fft // 2 + 1
    return (n_channels * n_channels, n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
903
904
905
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "Coh"
CSD

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

Cross-spectral density estimation operation

Source code in wandas/processing/spectral.py
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
class CSD(AudioOperation[NDArrayReal, NDArrayComplex]):
    """Cross-spectral density estimation operation"""

    name = "csd"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ):
        """
        Initialize cross-spectral density estimation operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window function, default is 'hann'
        detrend : str
            Type of detrend, default is 'constant'
        scaling : str
            Type of scaling, default is 'spectrum'
        average : str
            Method of averaging, default is 'mean'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "CSD"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.detrend = detrend
        self.scaling = scaling
        self.average = average
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels * channels, freqs)
        """
        n_channels = input_shape[0]
        n_freqs = self.n_fft // 2 + 1
        return (n_channels * n_channels, n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "CSD"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """Processor function for cross-spectral density estimation operation"""
        logger.debug(f"Applying CSD estimation to array with shape: {x.shape}")
        from scipy import signal as ss

        # Calculate all combinations using scipy's csd function
        _, csd_result = ss.csd(
            x=x[:, np.newaxis],
            y=x[np.newaxis, :],
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
            scaling=self.scaling,
            average=self.average,
        )

        # Reshape result to (n_channels * n_channels, n_freqs)
        result: NDArrayComplex = csd_result.transpose(1, 0, 2).reshape(
            -1, csd_result.shape[-1]
        )

        logger.debug(f"CSD estimation applied, result shape: {result.shape}")
        return result
Attributes
name = 'csd' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
detrend = detrend instance-attribute
scaling = scaling instance-attribute
average = average instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Initialize cross-spectral density estimation operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window function, default is 'hann' detrend : str Type of detrend, default is 'constant' scaling : str Type of scaling, default is 'spectrum' average : str Method of averaging, default is 'mean'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
):
    """
    Initialize cross-spectral density estimation operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window function, default is 'hann'
    detrend : str
        Type of detrend, default is 'constant'
    scaling : str
        Type of scaling, default is 'spectrum'
    average : str
        Method of averaging, default is 'mean'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "CSD"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.detrend = detrend
    self.scaling = scaling
    self.average = average
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=window,
        detrend=detrend,
        scaling=scaling,
        average=average,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels * channels, freqs)

Source code in wandas/processing/spectral.py
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels * channels, freqs)
    """
    n_channels = input_shape[0]
    n_freqs = self.n_fft // 2 + 1
    return (n_channels * n_channels, n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
1014
1015
1016
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "CSD"
TransferFunction

Bases: AudioOperation[NDArrayReal, NDArrayComplex]

Transfer function estimation operation

Source code in wandas/processing/spectral.py
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
class TransferFunction(AudioOperation[NDArrayReal, NDArrayComplex]):
    """Transfer function estimation operation"""

    name = "transfer_function"

    def __init__(
        self,
        sampling_rate: float,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
        detrend: str = "constant",
        scaling: str = "spectrum",
        average: str = "mean",
    ):
        """
        Initialize transfer function estimation operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        n_fft : int
            FFT size, default is 2048
        hop_length : int, optional
            Number of samples between frames. Default is win_length // 4
        win_length : int, optional
            Window length. Default is n_fft
        window : str
            Window function, default is 'hann'
        detrend : str
            Type of detrend, default is 'constant'
        scaling : str
            Type of scaling, default is 'spectrum'
        average : str
            Method of averaging, default is 'mean'

        Raises
        ------
        ValueError
            If n_fft is not positive, win_length > n_fft, or hop_length is invalid
        """
        # Validate and compute parameters
        actual_win_length, actual_hop_length = _validate_spectral_params(
            n_fft, win_length, hop_length, "Transfer function"
        )

        self.n_fft = n_fft
        self.win_length = actual_win_length
        self.hop_length = actual_hop_length
        self.window = window
        self.detrend = detrend
        self.scaling = scaling
        self.average = average
        super().__init__(
            sampling_rate,
            n_fft=n_fft,
            hop_length=self.hop_length,
            win_length=self.win_length,
            window=window,
            detrend=detrend,
            scaling=scaling,
            average=average,
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels * channels, freqs)
        """
        n_channels = input_shape[0]
        n_freqs = self.n_fft // 2 + 1
        return (n_channels * n_channels, n_freqs)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "H"

    def _process_array(self, x: NDArrayReal) -> NDArrayComplex:
        """Processor function for transfer function estimation operation"""
        logger.debug(
            f"Applying transfer function estimation to array with shape: {x.shape}"
        )
        from scipy import signal as ss

        # Calculate cross-spectral density between all channels
        f, p_yx = ss.csd(
            x=x[:, np.newaxis, :],
            y=x[np.newaxis, :, :],
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
            scaling=self.scaling,
            average=self.average,
            axis=-1,
        )
        # p_yx shape: (num_channels, num_channels, num_frequencies)

        # Calculate power spectral density for each channel
        f, p_xx = ss.welch(
            x=x,
            fs=self.sampling_rate,
            nperseg=self.win_length,
            noverlap=self.win_length - self.hop_length,
            nfft=self.n_fft,
            window=self.window,
            detrend=self.detrend,
            scaling=self.scaling,
            average=self.average,
            axis=-1,
        )
        # p_xx shape: (num_channels, num_frequencies)

        # Calculate transfer function H(f) = P_yx / P_xx
        h_f = p_yx / p_xx[np.newaxis, :, :]
        result: NDArrayComplex = h_f.transpose(1, 0, 2).reshape(-1, h_f.shape[-1])

        logger.debug(
            f"Transfer function estimation applied, result shape: {result.shape}"
        )
        return result
Attributes
name = 'transfer_function' class-attribute instance-attribute
n_fft = n_fft instance-attribute
win_length = actual_win_length instance-attribute
hop_length = actual_hop_length instance-attribute
window = window instance-attribute
detrend = detrend instance-attribute
scaling = scaling instance-attribute
average = average instance-attribute
Functions
__init__(sampling_rate, n_fft=2048, hop_length=None, win_length=None, window='hann', detrend='constant', scaling='spectrum', average='mean')

Initialize transfer function estimation operation

Parameters

sampling_rate : float Sampling rate (Hz) n_fft : int FFT size, default is 2048 hop_length : int, optional Number of samples between frames. Default is win_length // 4 win_length : int, optional Window length. Default is n_fft window : str Window function, default is 'hann' detrend : str Type of detrend, default is 'constant' scaling : str Type of scaling, default is 'spectrum' average : str Method of averaging, default is 'mean'

Raises

ValueError If n_fft is not positive, win_length > n_fft, or hop_length is invalid

Source code in wandas/processing/spectral.py
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
def __init__(
    self,
    sampling_rate: float,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
    detrend: str = "constant",
    scaling: str = "spectrum",
    average: str = "mean",
):
    """
    Initialize transfer function estimation operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    n_fft : int
        FFT size, default is 2048
    hop_length : int, optional
        Number of samples between frames. Default is win_length // 4
    win_length : int, optional
        Window length. Default is n_fft
    window : str
        Window function, default is 'hann'
    detrend : str
        Type of detrend, default is 'constant'
    scaling : str
        Type of scaling, default is 'spectrum'
    average : str
        Method of averaging, default is 'mean'

    Raises
    ------
    ValueError
        If n_fft is not positive, win_length > n_fft, or hop_length is invalid
    """
    # Validate and compute parameters
    actual_win_length, actual_hop_length = _validate_spectral_params(
        n_fft, win_length, hop_length, "Transfer function"
    )

    self.n_fft = n_fft
    self.win_length = actual_win_length
    self.hop_length = actual_hop_length
    self.window = window
    self.detrend = detrend
    self.scaling = scaling
    self.average = average
    super().__init__(
        sampling_rate,
        n_fft=n_fft,
        hop_length=self.hop_length,
        win_length=self.win_length,
        window=window,
        detrend=detrend,
        scaling=scaling,
        average=average,
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels * channels, freqs)

Source code in wandas/processing/spectral.py
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels * channels, freqs)
    """
    n_channels = input_shape[0]
    n_freqs = self.n_fft // 2 + 1
    return (n_channels * n_channels, n_freqs)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/spectral.py
1130
1131
1132
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "H"
Functions

stats

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
ABS

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Absolute value operation

Source code in wandas/processing/stats.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ABS(AudioOperation[NDArrayReal, NDArrayReal]):
    """Absolute value operation"""

    name = "abs"

    def __init__(self, sampling_rate: float):
        """
        Initialize absolute value operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        """
        super().__init__(sampling_rate)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "abs"

    def process(self, data: DaArray) -> DaArray:
        # map_blocksを使わず、直接Daskの集約関数を使用
        return da.abs(data)  # type: ignore [unused-ignore]
Attributes
name = 'abs' class-attribute instance-attribute
Functions
__init__(sampling_rate)

Initialize absolute value operation

Parameters

sampling_rate : float Sampling rate (Hz)

Source code in wandas/processing/stats.py
17
18
19
20
21
22
23
24
25
26
def __init__(self, sampling_rate: float):
    """
    Initialize absolute value operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    """
    super().__init__(sampling_rate)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
28
29
30
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "abs"
process(data)
Source code in wandas/processing/stats.py
32
33
34
def process(self, data: DaArray) -> DaArray:
    # map_blocksを使わず、直接Daskの集約関数を使用
    return da.abs(data)  # type: ignore [unused-ignore]
Power

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Power operation

Source code in wandas/processing/stats.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Power(AudioOperation[NDArrayReal, NDArrayReal]):
    """Power operation"""

    name = "power"

    def __init__(self, sampling_rate: float, exponent: float):
        """
        Initialize power operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        exponent : float
            Power exponent
        """
        super().__init__(sampling_rate)
        self.exp = exponent

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "pow"

    def process(self, data: DaArray) -> DaArray:
        # map_blocksを使わず、直接Daskの集約関数を使用
        return da.power(data, self.exp)  # type: ignore [unused-ignore]
Attributes
name = 'power' class-attribute instance-attribute
exp = exponent instance-attribute
Functions
__init__(sampling_rate, exponent)

Initialize power operation

Parameters

sampling_rate : float Sampling rate (Hz) exponent : float Power exponent

Source code in wandas/processing/stats.py
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(self, sampling_rate: float, exponent: float):
    """
    Initialize power operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    exponent : float
        Power exponent
    """
    super().__init__(sampling_rate)
    self.exp = exponent
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
56
57
58
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "pow"
process(data)
Source code in wandas/processing/stats.py
60
61
62
def process(self, data: DaArray) -> DaArray:
    # map_blocksを使わず、直接Daskの集約関数を使用
    return da.power(data, self.exp)  # type: ignore [unused-ignore]
Sum

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Sum calculation

Source code in wandas/processing/stats.py
65
66
67
68
69
70
71
72
73
74
75
76
class Sum(AudioOperation[NDArrayReal, NDArrayReal]):
    """Sum calculation"""

    name = "sum"

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "sum"

    def process(self, data: DaArray) -> DaArray:
        # Use Dask's aggregate function directly without map_blocks
        return data.sum(axis=0, keepdims=True)
Attributes
name = 'sum' class-attribute instance-attribute
Functions
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
70
71
72
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "sum"
process(data)
Source code in wandas/processing/stats.py
74
75
76
def process(self, data: DaArray) -> DaArray:
    # Use Dask's aggregate function directly without map_blocks
    return data.sum(axis=0, keepdims=True)
Mean

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Mean calculation

Source code in wandas/processing/stats.py
79
80
81
82
83
84
85
86
87
88
89
90
class Mean(AudioOperation[NDArrayReal, NDArrayReal]):
    """Mean calculation"""

    name = "mean"

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "mean"

    def process(self, data: DaArray) -> DaArray:
        # Use Dask's aggregate function directly without map_blocks
        return data.mean(axis=0, keepdims=True)
Attributes
name = 'mean' class-attribute instance-attribute
Functions
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
84
85
86
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "mean"
process(data)
Source code in wandas/processing/stats.py
88
89
90
def process(self, data: DaArray) -> DaArray:
    # Use Dask's aggregate function directly without map_blocks
    return data.mean(axis=0, keepdims=True)
ChannelDifference

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Channel difference calculation operation

Source code in wandas/processing/stats.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class ChannelDifference(AudioOperation[NDArrayReal, NDArrayReal]):
    """Channel difference calculation operation"""

    name = "channel_difference"
    other_channel: int

    def __init__(self, sampling_rate: float, other_channel: int = 0):
        """
        Initialize channel difference calculation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        other_channel : int
            Channel to calculate difference with, default is 0
        """
        self.other_channel = other_channel
        super().__init__(sampling_rate, other_channel=other_channel)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "diff"

    def process(self, data: DaArray) -> DaArray:
        # map_blocksを使わず、直接Daskの集約関数を使用
        result = data - data[self.other_channel]
        return result
Attributes
name = 'channel_difference' class-attribute instance-attribute
other_channel = other_channel instance-attribute
Functions
__init__(sampling_rate, other_channel=0)

Initialize channel difference calculation

Parameters

sampling_rate : float Sampling rate (Hz) other_channel : int Channel to calculate difference with, default is 0

Source code in wandas/processing/stats.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(self, sampling_rate: float, other_channel: int = 0):
    """
    Initialize channel difference calculation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    other_channel : int
        Channel to calculate difference with, default is 0
    """
    self.other_channel = other_channel
    super().__init__(sampling_rate, other_channel=other_channel)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/stats.py
113
114
115
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "diff"
process(data)
Source code in wandas/processing/stats.py
117
118
119
120
def process(self, data: DaArray) -> DaArray:
    # map_blocksを使わず、直接Daskの集約関数を使用
    result = data - data[self.other_channel]
    return result
Functions

temporal

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
ReSampling

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Resampling operation

Source code in wandas/processing/temporal.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class ReSampling(AudioOperation[NDArrayReal, NDArrayReal]):
    """Resampling operation"""

    name = "resampling"

    def __init__(self, sampling_rate: float, target_sr: float):
        """
        Initialize resampling operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        target_sampling_rate : float
            Target sampling rate (Hz)

        Raises
        ------
        ValueError
            If sampling_rate or target_sr is not positive
        """
        validate_sampling_rate(sampling_rate, "source sampling rate")
        validate_sampling_rate(target_sr, "target sampling rate")
        super().__init__(sampling_rate, target_sr=target_sr)
        self.target_sr = target_sr

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Update sampling rate to target sampling rate.

        Returns
        -------
        dict
            Metadata updates with new sampling rate

        Notes
        -----
        Resampling always produces output at target_sr, regardless of input
        sampling rate. All necessary parameters are provided at initialization.
        """
        return {"sampling_rate": self.target_sr}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate length after resampling
        ratio = float(self.target_sr) / float(self.sampling_rate)
        n_samples = int(np.ceil(input_shape[-1] * ratio))
        return (*input_shape[:-1], n_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "rs"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for resampling operation"""
        logger.debug(f"Applying resampling to array with shape: {x.shape}")
        result: NDArrayReal = librosa.resample(
            x, orig_sr=self.sampling_rate, target_sr=self.target_sr
        )
        logger.debug(f"Resampling applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'resampling' class-attribute instance-attribute
target_sr = target_sr instance-attribute
Functions
__init__(sampling_rate, target_sr)

Initialize resampling operation

Parameters

sampling_rate : float Sampling rate (Hz) target_sampling_rate : float Target sampling rate (Hz)

Raises

ValueError If sampling_rate or target_sr is not positive

Source code in wandas/processing/temporal.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def __init__(self, sampling_rate: float, target_sr: float):
    """
    Initialize resampling operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    target_sampling_rate : float
        Target sampling rate (Hz)

    Raises
    ------
    ValueError
        If sampling_rate or target_sr is not positive
    """
    validate_sampling_rate(sampling_rate, "source sampling rate")
    validate_sampling_rate(target_sr, "target sampling rate")
    super().__init__(sampling_rate, target_sr=target_sr)
    self.target_sr = target_sr
get_metadata_updates()

Update sampling rate to target sampling rate.

Returns

dict Metadata updates with new sampling rate

Notes

Resampling always produces output at target_sr, regardless of input sampling rate. All necessary parameters are provided at initialization.

Source code in wandas/processing/temporal.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Update sampling rate to target sampling rate.

    Returns
    -------
    dict
        Metadata updates with new sampling rate

    Notes
    -----
    Resampling always produces output at target_sr, regardless of input
    sampling rate. All necessary parameters are provided at initialization.
    """
    return {"sampling_rate": self.target_sr}
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/temporal.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate length after resampling
    ratio = float(self.target_sr) / float(self.sampling_rate)
    n_samples = int(np.ceil(input_shape[-1] * ratio))
    return (*input_shape[:-1], n_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/temporal.py
76
77
78
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "rs"
Trim

Bases: AudioOperation[NDArrayReal, NDArrayReal]

Trimming operation

Source code in wandas/processing/temporal.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
class Trim(AudioOperation[NDArrayReal, NDArrayReal]):
    """Trimming operation"""

    name = "trim"

    def __init__(
        self,
        sampling_rate: float,
        start: float,
        end: float,
    ):
        """
        Initialize trimming operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        start : float
            Start time for trimming (seconds)
        end : float
            End time for trimming (seconds)
        """
        super().__init__(sampling_rate, start=start, end=end)
        self.start = start
        self.end = end
        self.start_sample = int(start * sampling_rate)
        self.end_sample = int(end * sampling_rate)
        logger.debug(
            f"Initialized Trim operation with start: {self.start}, end: {self.end}"
        )

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        # Calculate length after trimming
        # Exclude parts where there is no signal
        end_sample = min(self.end_sample, input_shape[-1])
        n_samples = end_sample - self.start_sample
        return (*input_shape[:-1], n_samples)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "trim"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for trimming operation"""
        logger.debug(f"Applying trim to array with shape: {x.shape}")
        # Apply trimming
        result = x[..., self.start_sample : self.end_sample]
        logger.debug(f"Trim applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'trim' class-attribute instance-attribute
start = start instance-attribute
end = end instance-attribute
start_sample = int(start * sampling_rate) instance-attribute
end_sample = int(end * sampling_rate) instance-attribute
Functions
__init__(sampling_rate, start, end)

Initialize trimming operation

Parameters

sampling_rate : float Sampling rate (Hz) start : float Start time for trimming (seconds) end : float End time for trimming (seconds)

Source code in wandas/processing/temporal.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def __init__(
    self,
    sampling_rate: float,
    start: float,
    end: float,
):
    """
    Initialize trimming operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    start : float
        Start time for trimming (seconds)
    end : float
        End time for trimming (seconds)
    """
    super().__init__(sampling_rate, start=start, end=end)
    self.start = start
    self.end = end
    self.start_sample = int(start * sampling_rate)
    self.end_sample = int(end * sampling_rate)
    logger.debug(
        f"Initialized Trim operation with start: {self.start}, end: {self.end}"
    )
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/temporal.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    # Calculate length after trimming
    # Exclude parts where there is no signal
    end_sample = min(self.end_sample, input_shape[-1])
    n_samples = end_sample - self.start_sample
    return (*input_shape[:-1], n_samples)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/temporal.py
142
143
144
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "trim"
FixLength

Bases: AudioOperation[NDArrayReal, NDArrayReal]

信号の長さを指定された長さに調整する操作

Source code in wandas/processing/temporal.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class FixLength(AudioOperation[NDArrayReal, NDArrayReal]):
    """信号の長さを指定された長さに調整する操作"""

    name = "fix_length"

    def __init__(
        self,
        sampling_rate: float,
        length: int | None = None,
        duration: float | None = None,
    ):
        """
        Initialize fix length operation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        length : Optional[int]
            Target length for fixing
        duration : Optional[float]
            Target length for fixing
        """
        if length is None:
            if duration is None:
                raise ValueError("Either length or duration must be provided.")
            else:
                length = int(duration * sampling_rate)
        self.target_length = length

        super().__init__(sampling_rate, target_length=self.target_length)

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape

        Returns
        -------
        tuple
            Output data shape
        """
        return (*input_shape[:-1], self.target_length)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "fix"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for padding operation"""
        logger.debug(f"Applying padding to array with shape: {x.shape}")
        # Apply padding
        pad_width = self.target_length - x.shape[-1]
        if pad_width > 0:
            result = np.pad(x, ((0, 0), (0, pad_width)), mode="constant")
        else:
            result = x[..., : self.target_length]
        logger.debug(f"Padding applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'fix_length' class-attribute instance-attribute
target_length = length instance-attribute
Functions
__init__(sampling_rate, length=None, duration=None)

Initialize fix length operation

Parameters

sampling_rate : float Sampling rate (Hz) length : Optional[int] Target length for fixing duration : Optional[float] Target length for fixing

Source code in wandas/processing/temporal.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
def __init__(
    self,
    sampling_rate: float,
    length: int | None = None,
    duration: float | None = None,
):
    """
    Initialize fix length operation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    length : Optional[int]
        Target length for fixing
    duration : Optional[float]
        Target length for fixing
    """
    if length is None:
        if duration is None:
            raise ValueError("Either length or duration must be provided.")
        else:
            length = int(duration * sampling_rate)
    self.target_length = length

    super().__init__(sampling_rate, target_length=self.target_length)
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape

Returns

tuple Output data shape

Source code in wandas/processing/temporal.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape

    Returns
    -------
    tuple
        Output data shape
    """
    return (*input_shape[:-1], self.target_length)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/temporal.py
203
204
205
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "fix"
RmsTrend

Bases: AudioOperation[NDArrayReal, NDArrayReal]

RMS calculation

Source code in wandas/processing/temporal.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
class RmsTrend(AudioOperation[NDArrayReal, NDArrayReal]):
    """RMS calculation"""

    name = "rms_trend"
    frame_length: int
    hop_length: int
    Aw: bool

    def __init__(
        self,
        sampling_rate: float,
        frame_length: int = 2048,
        hop_length: int = 512,
        ref: list[float] | float = 1.0,
        dB: bool = False,  # noqa: N803
        Aw: bool = False,  # noqa: N803
    ) -> None:
        """
        Initialize RMS calculation

        Parameters
        ----------
        sampling_rate : float
            Sampling rate (Hz)
        frame_length : int
            Frame length, default is 2048
        hop_length : int
            Hop length, default is 512
        ref : Union[list[float], float]
            Reference value(s) for dB calculation
        dB : bool
            Whether to convert to decibels
        Aw : bool
            Whether to apply A-weighting before RMS calculation
        """
        self.frame_length = frame_length
        self.hop_length = hop_length
        self.dB = dB
        self.Aw = Aw
        self.ref = np.array(ref if isinstance(ref, list) else [ref])
        super().__init__(
            sampling_rate,
            frame_length=frame_length,
            hop_length=hop_length,
            dB=dB,
            Aw=Aw,
            ref=self.ref,
        )

    def get_metadata_updates(self) -> dict[str, Any]:
        """
        Update sampling rate based on hop length.

        Returns
        -------
        dict
            Metadata updates with new sampling rate based on hop length

        Notes
        -----
        The output sampling rate is determined by downsampling the input
        by hop_length. All necessary parameters are provided at initialization.
        """
        new_sr = self.sampling_rate / self.hop_length
        return {"sampling_rate": new_sr}

    def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
        """
        Calculate output data shape after operation

        Parameters
        ----------
        input_shape : tuple
            Input data shape (channels, samples)

        Returns
        -------
        tuple
            Output data shape (channels, frames)
        """
        n_frames = librosa.feature.rms(
            y=np.ones((1, input_shape[-1])),
            frame_length=self.frame_length,
            hop_length=self.hop_length,
        ).shape[-1]
        return (*input_shape[:-1], n_frames)

    def get_display_name(self) -> str:
        """Get display name for the operation for use in channel labels."""
        return "RMS"

    def _process_array(self, x: NDArrayReal) -> NDArrayReal:
        """Create processor function for RMS calculation"""
        logger.debug(f"Applying RMS to array with shape: {x.shape}")

        if self.Aw:
            # Apply A-weighting
            _x = A_weight(x, self.sampling_rate)
            if isinstance(_x, np.ndarray):
                # A_weightがタプルを返す場合、最初の要素を使用
                x = _x
            elif isinstance(_x, tuple):
                # Use the first element if A_weight returns a tuple
                x = _x[0]
            else:
                raise ValueError("A_weighting returned an unexpected type.")

        # Calculate RMS
        result: NDArrayReal = librosa.feature.rms(
            y=x, frame_length=self.frame_length, hop_length=self.hop_length
        )[..., 0, :]

        if self.dB:
            # Convert to dB
            result = 20 * np.log10(
                np.maximum(result / self.ref[..., np.newaxis], 1e-12)
            )
        #
        logger.debug(f"RMS applied, returning result with shape: {result.shape}")
        return result
Attributes
name = 'rms_trend' class-attribute instance-attribute
frame_length = frame_length instance-attribute
hop_length = hop_length instance-attribute
dB = dB instance-attribute
Aw = Aw instance-attribute
ref = np.array(ref if isinstance(ref, list) else [ref]) instance-attribute
Functions
__init__(sampling_rate, frame_length=2048, hop_length=512, ref=1.0, dB=False, Aw=False)

Initialize RMS calculation

Parameters

sampling_rate : float Sampling rate (Hz) frame_length : int Frame length, default is 2048 hop_length : int Hop length, default is 512 ref : Union[list[float], float] Reference value(s) for dB calculation dB : bool Whether to convert to decibels Aw : bool Whether to apply A-weighting before RMS calculation

Source code in wandas/processing/temporal.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def __init__(
    self,
    sampling_rate: float,
    frame_length: int = 2048,
    hop_length: int = 512,
    ref: list[float] | float = 1.0,
    dB: bool = False,  # noqa: N803
    Aw: bool = False,  # noqa: N803
) -> None:
    """
    Initialize RMS calculation

    Parameters
    ----------
    sampling_rate : float
        Sampling rate (Hz)
    frame_length : int
        Frame length, default is 2048
    hop_length : int
        Hop length, default is 512
    ref : Union[list[float], float]
        Reference value(s) for dB calculation
    dB : bool
        Whether to convert to decibels
    Aw : bool
        Whether to apply A-weighting before RMS calculation
    """
    self.frame_length = frame_length
    self.hop_length = hop_length
    self.dB = dB
    self.Aw = Aw
    self.ref = np.array(ref if isinstance(ref, list) else [ref])
    super().__init__(
        sampling_rate,
        frame_length=frame_length,
        hop_length=hop_length,
        dB=dB,
        Aw=Aw,
        ref=self.ref,
    )
get_metadata_updates()

Update sampling rate based on hop length.

Returns

dict Metadata updates with new sampling rate based on hop length

Notes

The output sampling rate is determined by downsampling the input by hop_length. All necessary parameters are provided at initialization.

Source code in wandas/processing/temporal.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def get_metadata_updates(self) -> dict[str, Any]:
    """
    Update sampling rate based on hop length.

    Returns
    -------
    dict
        Metadata updates with new sampling rate based on hop length

    Notes
    -----
    The output sampling rate is determined by downsampling the input
    by hop_length. All necessary parameters are provided at initialization.
    """
    new_sr = self.sampling_rate / self.hop_length
    return {"sampling_rate": new_sr}
calculate_output_shape(input_shape)

Calculate output data shape after operation

Parameters

input_shape : tuple Input data shape (channels, samples)

Returns

tuple Output data shape (channels, frames)

Source code in wandas/processing/temporal.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def calculate_output_shape(self, input_shape: tuple[int, ...]) -> tuple[int, ...]:
    """
    Calculate output data shape after operation

    Parameters
    ----------
    input_shape : tuple
        Input data shape (channels, samples)

    Returns
    -------
    tuple
        Output data shape (channels, frames)
    """
    n_frames = librosa.feature.rms(
        y=np.ones((1, input_shape[-1])),
        frame_length=self.frame_length,
        hop_length=self.hop_length,
    ).shape[-1]
    return (*input_shape[:-1], n_frames)
get_display_name()

Get display name for the operation for use in channel labels.

Source code in wandas/processing/temporal.py
307
308
309
def get_display_name(self) -> str:
    """Get display name for the operation for use in channel labels."""
    return "RMS"
Functions

入出力モジュール

入出力モジュールはファイルの読み書き機能を提供します。

wandas.io

Attributes

__all__ = ['read_wav', 'write_wav', 'load', 'save'] module-attribute

Functions

read_wav(filename, labels=None)

Read a WAV file and create a ChannelFrame object.

Parameters

filename : str Path to the WAV file or URL to the WAV file. labels : list of str, optional Labels for each channel.

Returns

ChannelFrame ChannelFrame object containing the audio data.

Source code in wandas/io/wav_io.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def read_wav(filename: str, labels: list[str] | None = None) -> "ChannelFrame":
    """
    Read a WAV file and create a ChannelFrame object.

    Parameters
    ----------
    filename : str
        Path to the WAV file or URL to the WAV file.
    labels : list of str, optional
        Labels for each channel.

    Returns
    -------
    ChannelFrame
        ChannelFrame object containing the audio data.
    """
    from wandas.frames.channel import ChannelFrame

    # ファイル名がURLかどうかを判断
    if filename.startswith("http://") or filename.startswith("https://"):
        # URLの場合、requestsを使用してダウンロード

        response = requests.get(filename)
        file_obj = io.BytesIO(response.content)
        file_label = os.path.basename(filename)
        # メモリマッピングは使用せずに読み込む
        sampling_rate, data = wavfile.read(file_obj)
    else:
        # ローカルファイルパスの場合
        file_label = os.path.basename(filename)
        # データの読み込み(メモリマッピングを使用)
        sampling_rate, data = wavfile.read(filename, mmap=True)

    # データを(num_channels, num_samples)形状のNumPy配列に変換
    if data.ndim == 1:
        # モノラル:(samples,) -> (1, samples)
        data = np.expand_dims(data, axis=0)
    else:
        # ステレオ:(samples, channels) -> (channels, samples)
        data = data.T

    # NumPy配列からChannelFrameを作成
    channel_frame = ChannelFrame.from_numpy(
        data=data,
        sampling_rate=sampling_rate,
        label=file_label,
        ch_labels=labels,
    )

    return channel_frame

write_wav(filename, target, format=None)

Write a ChannelFrame object to a WAV file.

Parameters

filename : str Path to the WAV file. target : ChannelFrame ChannelFrame object containing the data to write. format : str, optional File format. If None, determined from file extension.

Raises

ValueError If target is not a ChannelFrame object.

Source code in wandas/io/wav_io.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def write_wav(filename: str, target: "ChannelFrame", format: str | None = None) -> None:
    """
    Write a ChannelFrame object to a WAV file.

    Parameters
    ----------
    filename : str
        Path to the WAV file.
    target : ChannelFrame
        ChannelFrame object containing the data to write.
    format : str, optional
        File format. If None, determined from file extension.

    Raises
    ------
    ValueError
        If target is not a ChannelFrame object.
    """
    from wandas.frames.channel import ChannelFrame

    if not isinstance(target, ChannelFrame):
        raise ValueError("target must be a ChannelFrame object.")

    logger.debug(f"Saving audio data to file: {filename} (will compute now)")
    data = target.compute()
    data = data.T
    if data.shape[1] == 1:
        data = data.squeeze(axis=1)
    if data.dtype == float and max([np.abs(data.max()), np.abs(data.min())]) < 1:
        sf.write(
            str(filename),
            data,
            int(target.sampling_rate),
            subtype="FLOAT",
            format=format,
        )
    else:
        sf.write(str(filename), data, int(target.sampling_rate), format=format)
    logger.debug(f"Save complete: {filename}")

load(path, *, format='hdf5')

Load a ChannelFrame object from a WDF (Wandas Data File) file.

Parameters:

Name Type Description Default
path str | Path

Path to the WDF file to load.

required
format str

Format of the file. Currently only "hdf5" is supported.

'hdf5'

Returns:

Type Description
ChannelFrame

A new ChannelFrame object with data and metadata loaded from the file.

Raises:

Type Description
FileNotFoundError

If the file doesn't exist.

NotImplementedError

If format is not "hdf5".

ValueError

If the file format is invalid or incompatible.

Example

cf = ChannelFrame.load("audio_data.wdf")

Source code in wandas/io/wdf_io.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def load(path: str | Path, *, format: str = "hdf5") -> "ChannelFrame":
    """Load a ChannelFrame object from a WDF (Wandas Data File) file.

    Args:
        path: Path to the WDF file to load.
        format: Format of the file. Currently only "hdf5" is supported.

    Returns:
        A new ChannelFrame object with data and metadata loaded from the file.

    Raises:
        FileNotFoundError: If the file doesn't exist.
        NotImplementedError: If format is not "hdf5".
        ValueError: If the file format is invalid or incompatible.

    Example:
        >>> cf = ChannelFrame.load("audio_data.wdf")
    """
    # Ensure ChannelFrame is imported here to avoid circular imports
    from ..core.metadata import ChannelMetadata
    from ..frames.channel import ChannelFrame

    if format != "hdf5":
        raise NotImplementedError(f"Format '{format}' is not supported")

    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f"File not found: {path}")

    logger.debug(f"Loading ChannelFrame from {path}")

    with h5py.File(path, "r") as f:
        # Check format version for compatibility
        version = f.attrs.get("version", "unknown")
        if version != WDF_FORMAT_VERSION:
            logger.warning(
                f"File format version mismatch: file={version}, current={WDF_FORMAT_VERSION}"  # noqa: E501
            )

        # Get global attributes
        sampling_rate = float(f.attrs["sampling_rate"])
        frame_label = f.attrs.get("label", "")

        # Get frame metadata
        frame_metadata = {}
        if "meta" in f:
            meta_json = f["meta"].attrs.get("json", "{}")
            frame_metadata = json.loads(meta_json)

        # Load operation history
        operation_history = []
        if "operation_history" in f:
            op_grp = f["operation_history"]
            # Sort operation indices numerically
            op_indices = sorted([int(key.split("_")[1]) for key in op_grp.keys()])

            for idx in op_indices:
                op_sub_grp = op_grp[f"operation_{idx}"]
                op_dict = {}
                for attr_name in op_sub_grp.attrs:
                    attr_value = op_sub_grp.attrs[attr_name]
                    # Try to deserialize JSON, fallback to string
                    try:
                        op_dict[attr_name] = json.loads(attr_value)
                    except (json.JSONDecodeError, TypeError):
                        op_dict[attr_name] = attr_value
                operation_history.append(op_dict)

        # Load channel data and metadata
        all_channel_data = []
        channel_metadata_list = []

        if "channels" in f:
            channels_group = f["channels"]
            # Sort channel indices numerically
            channel_indices = sorted([int(key) for key in channels_group.keys()])

            for idx in channel_indices:
                ch_group = channels_group[f"{idx}"]

                # Load channel data
                channel_data = ch_group["data"][()]

                # Append to combined array
                all_channel_data.append(channel_data)

                # Load channel metadata
                label = ch_group.attrs.get("label", f"Ch{idx}")
                unit = ch_group.attrs.get("unit", "")

                # Load additional metadata if present
                ch_extra = {}
                if "metadata_json" in ch_group.attrs:
                    ch_extra = json.loads(ch_group.attrs["metadata_json"])

                # Create ChannelMetadata object
                channel_metadata = ChannelMetadata(
                    label=label, unit=unit, extra=ch_extra
                )
                channel_metadata_list.append(channel_metadata)

        # Stack channel data into a single array
        if all_channel_data:
            combined_data = np.stack(all_channel_data, axis=0)
        else:
            raise ValueError("No channel data found in the file")

        # Create a new ChannelFrame
        dask_data = da_from_array(combined_data)

        cf = ChannelFrame(
            data=dask_data,
            sampling_rate=sampling_rate,
            label=frame_label if frame_label else None,
            metadata=frame_metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata_list,
        )

        logger.debug(
            f"ChannelFrame loaded from {path}: {len(cf)} channels, {cf.n_samples} samples"  # noqa: E501
        )
        return cf

save(frame, path, *, format='hdf5', compress='gzip', overwrite=False, dtype=None)

Save a frame to a file.

Parameters:

Name Type Description Default
frame BaseFrame[Any]

The frame to save.

required
path str | Path

Path to save the file. '.wdf' extension will be added if not present.

required
format str

Format to use (currently only 'hdf5' is supported)

'hdf5'
compress str | None

Compression method ('gzip' by default, None for no compression)

'gzip'
overwrite bool

Whether to overwrite existing file

False
dtype str | dtype[Any] | None

Optional data type conversion before saving (e.g. 'float32')

None

Raises:

Type Description
FileExistsError

If the file exists and overwrite=False.

NotImplementedError

For unsupported formats.

Source code in wandas/io/wdf_io.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def save(
    frame: BaseFrame[Any],
    path: str | Path,
    *,
    format: str = "hdf5",
    compress: str | None = "gzip",
    overwrite: bool = False,
    dtype: str | np.dtype[Any] | None = None,
) -> None:
    """Save a frame to a file.

    Args:
        frame: The frame to save.
        path: Path to save the file. '.wdf' extension will be added if not present.
        format: Format to use (currently only 'hdf5' is supported)
        compress: Compression method ('gzip' by default, None for no compression)
        overwrite: Whether to overwrite existing file
        dtype: Optional data type conversion before saving (e.g. 'float32')

    Raises:
        FileExistsError: If the file exists and overwrite=False.
        NotImplementedError: For unsupported formats.
    """
    # Handle path
    path = Path(path)
    if path.suffix != ".wdf":
        path = path.with_suffix(".wdf")

    # Check if file exists
    if path.exists() and not overwrite:
        raise FileExistsError(
            f"File {path} already exists. Set overwrite=True to overwrite."
        )

    # Currently only HDF5 is supported
    if format.lower() != "hdf5":
        raise NotImplementedError(
            f"Format {format} not supported. Only 'hdf5' is currently implemented."
        )

    # Compute data arrays (this triggers actual computation)
    logger.info("Computing data arrays for saving...")
    computed_data = frame.compute()
    if dtype is not None:
        computed_data = computed_data.astype(dtype)

    # Create file
    logger.info(f"Creating HDF5 file at {path}...")
    with h5py.File(path, "w") as f:
        # Set file version
        f.attrs["version"] = WDF_FORMAT_VERSION

        # Store frame metadata
        f.attrs["sampling_rate"] = frame.sampling_rate
        f.attrs["label"] = frame.label or ""
        f.attrs["frame_type"] = type(frame).__name__

        # Create channels group
        channels_grp = f.create_group("channels")

        # Store each channel
        for i, (channel_data, ch_meta) in enumerate(
            zip(computed_data, frame._channel_metadata)
        ):
            ch_grp = channels_grp.create_group(f"{i}")

            # Store channel data
            if compress:
                ch_grp.create_dataset("data", data=channel_data, compression=compress)
            else:
                ch_grp.create_dataset("data", data=channel_data)

            # Store metadata
            ch_grp.attrs["label"] = ch_meta.label
            ch_grp.attrs["unit"] = ch_meta.unit

            # Store extra metadata as JSON
            if ch_meta.extra:
                ch_grp.attrs["metadata_json"] = json.dumps(ch_meta.extra)

        # Store operation history
        if frame.operation_history:
            op_grp = f.create_group("operation_history")
            for i, op in enumerate(frame.operation_history):
                op_sub_grp = op_grp.create_group(f"operation_{i}")
                for k, v in op.items():
                    # Store simple attributes directly
                    if isinstance(v, str | int | float | bool | np.number):
                        op_sub_grp.attrs[k] = v
                    else:
                        # For complex types, serialize to JSON
                        try:
                            op_sub_grp.attrs[k] = json.dumps(v)
                        except (TypeError, OverflowError) as e:
                            logger.warning(
                                f"Could not serialize operation key '{k}': {e}"
                            )
                            op_sub_grp.attrs[k] = str(v)

        # Store frame metadata
        if frame.metadata:
            meta_grp = f.create_group("meta")
            # Store metadata as JSON
            meta_grp.attrs["json"] = json.dumps(frame.metadata)

            # Also store individual metadata items as attributes for compatibility
            for k, v in frame.metadata.items():
                if isinstance(v, str | int | float | bool | np.number):
                    meta_grp.attrs[k] = v

    logger.info(f"Frame saved to {path}")

Modules

readers

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
CSVFileInfoParams

Bases: TypedDict

Type definition for CSV file reader parameters in get_file_info.

Parameters

delimiter : str Delimiter character. Default is ",". header : Optional[int] Row number to use as header. Default is 0 (first row). Set to None if no header. time_column : Union[int, str] Index or name of the time column. Default is 0.

Source code in wandas/io/readers.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class CSVFileInfoParams(TypedDict, total=False):
    """Type definition for CSV file reader parameters in get_file_info.

    Parameters
    ----------
    delimiter : str
        Delimiter character. Default is ",".
    header : Optional[int]
        Row number to use as header. Default is 0 (first row).
        Set to None if no header.
    time_column : Union[int, str]
        Index or name of the time column. Default is 0.
    """

    delimiter: str
    header: int | None
    time_column: int | str
Attributes
delimiter instance-attribute
header instance-attribute
time_column instance-attribute
CSVGetDataParams

Bases: TypedDict

Type definition for CSV file reader parameters in get_data.

Parameters

delimiter : str Delimiter character. Default is ",". header : Optional[int] Row number to use as header. Default is 0. time_column : Union[int, str] Index or name of the time column. Default is 0.

Source code in wandas/io/readers.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class CSVGetDataParams(TypedDict, total=False):
    """Type definition for CSV file reader parameters in get_data.

    Parameters
    ----------
    delimiter : str
        Delimiter character. Default is ",".
    header : Optional[int]
        Row number to use as header. Default is 0.
    time_column : Union[int, str]
        Index or name of the time column. Default is 0.
    """

    delimiter: str
    header: int | None
    time_column: int | str
Attributes
delimiter instance-attribute
header instance-attribute
time_column instance-attribute
FileReader

Bases: ABC

Base class for audio file readers.

Source code in wandas/io/readers.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
class FileReader(ABC):
    """Base class for audio file readers."""

    # Class attribute for supported file extensions
    supported_extensions: list[str] = []

    @classmethod
    @abstractmethod
    def get_file_info(cls, path: str | Path, **kwargs: Any) -> dict[str, Any]:
        """Get basic information about the audio file.

        Args:
            path: Path to the file.
            **kwargs: Additional parameters specific to the file reader.

        Returns:
            Dictionary containing file information including:
            - samplerate: Sampling rate in Hz
            - channels: Number of channels
            - frames: Total number of frames
            - format: File format
            - duration: Duration in seconds
        """
        pass

    @classmethod
    @abstractmethod
    def get_data(
        cls,
        path: str | Path,
        channels: list[int],
        start_idx: int,
        frames: int,
        **kwargs: Any,
    ) -> ArrayLike:
        """Read audio data from the file.

        Args:
            path: Path to the file.
            channels: List of channel indices to read.
            start_idx: Starting frame index.
            frames: Number of frames to read.
            **kwargs: Additional parameters specific to the file reader.

        Returns:
            Array of shape (channels, frames) containing the audio data.
        """
        pass

    @classmethod
    def can_read(cls, path: str | Path) -> bool:
        """Check if this reader can handle the file based on extension."""
        ext = Path(path).suffix.lower()
        return ext in cls.supported_extensions
Attributes
supported_extensions = [] class-attribute instance-attribute
Functions
get_file_info(path, **kwargs) abstractmethod classmethod

Get basic information about the audio file.

Parameters:

Name Type Description Default
path str | Path

Path to the file.

required
**kwargs Any

Additional parameters specific to the file reader.

{}

Returns:

Type Description
dict[str, Any]

Dictionary containing file information including:

dict[str, Any]
  • samplerate: Sampling rate in Hz
dict[str, Any]
  • channels: Number of channels
dict[str, Any]
  • frames: Total number of frames
dict[str, Any]
  • format: File format
dict[str, Any]
  • duration: Duration in seconds
Source code in wandas/io/readers.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@classmethod
@abstractmethod
def get_file_info(cls, path: str | Path, **kwargs: Any) -> dict[str, Any]:
    """Get basic information about the audio file.

    Args:
        path: Path to the file.
        **kwargs: Additional parameters specific to the file reader.

    Returns:
        Dictionary containing file information including:
        - samplerate: Sampling rate in Hz
        - channels: Number of channels
        - frames: Total number of frames
        - format: File format
        - duration: Duration in seconds
    """
    pass
get_data(path, channels, start_idx, frames, **kwargs) abstractmethod classmethod

Read audio data from the file.

Parameters:

Name Type Description Default
path str | Path

Path to the file.

required
channels list[int]

List of channel indices to read.

required
start_idx int

Starting frame index.

required
frames int

Number of frames to read.

required
**kwargs Any

Additional parameters specific to the file reader.

{}

Returns:

Type Description
ArrayLike

Array of shape (channels, frames) containing the audio data.

Source code in wandas/io/readers.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@classmethod
@abstractmethod
def get_data(
    cls,
    path: str | Path,
    channels: list[int],
    start_idx: int,
    frames: int,
    **kwargs: Any,
) -> ArrayLike:
    """Read audio data from the file.

    Args:
        path: Path to the file.
        channels: List of channel indices to read.
        start_idx: Starting frame index.
        frames: Number of frames to read.
        **kwargs: Additional parameters specific to the file reader.

    Returns:
        Array of shape (channels, frames) containing the audio data.
    """
    pass
can_read(path) classmethod

Check if this reader can handle the file based on extension.

Source code in wandas/io/readers.py
100
101
102
103
104
@classmethod
def can_read(cls, path: str | Path) -> bool:
    """Check if this reader can handle the file based on extension."""
    ext = Path(path).suffix.lower()
    return ext in cls.supported_extensions
SoundFileReader

Bases: FileReader

Audio file reader using SoundFile library.

Source code in wandas/io/readers.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
class SoundFileReader(FileReader):
    """Audio file reader using SoundFile library."""

    # SoundFile supported formats
    supported_extensions = [".wav", ".flac", ".ogg", ".aiff", ".aif", ".snd"]

    @classmethod
    def get_file_info(cls, path: str | Path, **kwargs: Any) -> dict[str, Any]:
        """Get basic information about the audio file."""
        info = sf.info(str(path))
        return {
            "samplerate": info.samplerate,
            "channels": info.channels,
            "frames": info.frames,
            "format": info.format,
            "subtype": info.subtype,
            "duration": info.frames / info.samplerate,
        }

    @classmethod
    def get_data(
        cls,
        path: str | Path,
        channels: list[int],
        start_idx: int,
        frames: int,
        **kwargs: Any,
    ) -> ArrayLike:
        """Read audio data from the file."""
        logger.debug(f"Reading {frames} frames from {path} starting at {start_idx}")

        with sf.SoundFile(str(path)) as f:
            if start_idx > 0:
                f.seek(start_idx)
            data = f.read(frames=frames, dtype="float32", always_2d=True)

            # Select requested channels
            if len(channels) < f.channels:
                data = data[:, channels]

            # Transpose to get (channels, samples) format
            result: ArrayLike = data.T
            if not isinstance(result, np.ndarray):
                raise ValueError("Unexpected data type after reading file")

        _shape = result.shape
        logger.debug(f"File read complete, returning data with shape {_shape}")
        return result
Attributes
supported_extensions = ['.wav', '.flac', '.ogg', '.aiff', '.aif', '.snd'] class-attribute instance-attribute
Functions
get_file_info(path, **kwargs) classmethod

Get basic information about the audio file.

Source code in wandas/io/readers.py
113
114
115
116
117
118
119
120
121
122
123
124
@classmethod
def get_file_info(cls, path: str | Path, **kwargs: Any) -> dict[str, Any]:
    """Get basic information about the audio file."""
    info = sf.info(str(path))
    return {
        "samplerate": info.samplerate,
        "channels": info.channels,
        "frames": info.frames,
        "format": info.format,
        "subtype": info.subtype,
        "duration": info.frames / info.samplerate,
    }
get_data(path, channels, start_idx, frames, **kwargs) classmethod

Read audio data from the file.

Source code in wandas/io/readers.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
@classmethod
def get_data(
    cls,
    path: str | Path,
    channels: list[int],
    start_idx: int,
    frames: int,
    **kwargs: Any,
) -> ArrayLike:
    """Read audio data from the file."""
    logger.debug(f"Reading {frames} frames from {path} starting at {start_idx}")

    with sf.SoundFile(str(path)) as f:
        if start_idx > 0:
            f.seek(start_idx)
        data = f.read(frames=frames, dtype="float32", always_2d=True)

        # Select requested channels
        if len(channels) < f.channels:
            data = data[:, channels]

        # Transpose to get (channels, samples) format
        result: ArrayLike = data.T
        if not isinstance(result, np.ndarray):
            raise ValueError("Unexpected data type after reading file")

    _shape = result.shape
    logger.debug(f"File read complete, returning data with shape {_shape}")
    return result
CSVFileReader

Bases: FileReader

CSV file reader for time series data.

Source code in wandas/io/readers.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
class CSVFileReader(FileReader):
    """CSV file reader for time series data."""

    # CSV supported formats
    supported_extensions = [".csv"]

    @classmethod
    def get_file_info(
        cls,
        path: str | Path,
        **kwargs: Any,
    ) -> dict[str, Any]:
        """Get basic information about the CSV file.

        Parameters
        ----------
        path : Union[str, Path]
            Path to the CSV file.
        **kwargs : Any
            Additional parameters for CSV reading. Supported parameters:

            - delimiter : str, default=","
                Delimiter character.
            - header : Optional[int], default=0
                Row number to use as header. Set to None if no header.
            - time_column : Union[int, str], default=0
                Index or name of the time column.

        Returns
        -------
        dict[str, Any]
            Dictionary containing file information including:
            - samplerate: Estimated sampling rate in Hz
            - channels: Number of data channels (excluding time column)
            - frames: Total number of frames
            - format: "CSV"
            - duration: Duration in seconds (or None if cannot be calculated)
            - ch_labels: List of channel labels

        Notes
        -----
        This method accepts CSV-specific parameters through kwargs.
        See CSVFileInfoParams for supported parameter types.
        """
        # Extract parameters with defaults
        delimiter: str = kwargs.get("delimiter", ",")
        header: int | None = kwargs.get("header", 0)
        time_column: int | str = kwargs.get("time_column", 0)

        # Read first few lines to determine structure
        df = pd.read_csv(path, delimiter=delimiter, header=header)

        # Estimate sampling rate from first column (assuming it's time)
        try:
            # Get time column as Series
            if isinstance(time_column, str):
                time_series = df[time_column]
            else:
                time_series = df.iloc[:, time_column]
            time_values = np.array(time_series.values)
            if len(time_values) > 1:
                # Use round() instead of int() to handle floating-point precision issues
                estimated_sr = round(1 / np.mean(np.diff(time_values)))
            else:
                estimated_sr = 0  # Cannot determine from single row
        except Exception:
            estimated_sr = 0  # Default if can't calculate

        frames = df.shape[0]
        duration = frames / estimated_sr if estimated_sr > 0 else None

        # Return file info
        return {
            "samplerate": estimated_sr,
            "channels": df.shape[1] - 1,  # Assuming first column is time
            "frames": frames,
            "format": "CSV",
            "duration": duration,
            "ch_labels": df.columns[1:].tolist(),  # Assuming first column is time
        }

    @classmethod
    def get_data(
        cls,
        path: str | Path,
        channels: list[int],
        start_idx: int,
        frames: int,
        **kwargs: Any,
    ) -> ArrayLike:
        """Read data from the CSV file.

        Parameters
        ----------
        path : Union[str, Path]
            Path to the CSV file.
        channels : list[int]
            List of channel indices to read.
        start_idx : int
            Starting frame index.
        frames : int
            Number of frames to read.
        **kwargs : Any
            Additional parameters for CSV reading. Supported parameters:

            - delimiter : str, default=","
                Delimiter character.
            - header : Optional[int], default=0
                Row number to use as header.
            - time_column : Union[int, str], default=0
                Index or name of the time column.

        Returns
        -------
        ArrayLike
            Array of shape (channels, frames) containing the data.

        Notes
        -----
        This method accepts CSV-specific parameters through kwargs.
        See CSVGetDataParams for supported parameter types.
        """
        # Extract parameters with defaults
        time_column: int | str = kwargs.get("time_column", 0)
        delimiter: str = kwargs.get("delimiter", ",")
        header: int | None = kwargs.get("header", 0)

        logger.debug(f"Reading CSV data from {path} starting at {start_idx}")

        # Read the CSV file
        df = pd.read_csv(path, delimiter=delimiter, header=header)

        # Remove time column
        df = df.drop(
            columns=[time_column]
            if isinstance(time_column, str)
            else df.columns[time_column]
        )

        # Select requested channels - adjust indices to account for time column removal
        if channels:
            try:
                data_df = df.iloc[:, channels]
            except IndexError:
                raise ValueError(f"Requested channels {channels} out of range")
        else:
            data_df = df

        # Handle start_idx and frames for partial reading
        end_idx = start_idx + frames if frames > 0 else None
        data_df = data_df.iloc[start_idx:end_idx]

        # Convert to numpy array and transpose to (channels, samples) format
        result = data_df.values.T

        if not isinstance(result, np.ndarray):
            raise ValueError("Unexpected data type after reading file")

        _shape = result.shape
        logger.debug(f"CSV read complete, returning data with shape {_shape}")
        return result
Attributes
supported_extensions = ['.csv'] class-attribute instance-attribute
Functions
get_file_info(path, **kwargs) classmethod

Get basic information about the CSV file.

Parameters

path : Union[str, Path] Path to the CSV file. **kwargs : Any Additional parameters for CSV reading. Supported parameters:

- delimiter : str, default=","
    Delimiter character.
- header : Optional[int], default=0
    Row number to use as header. Set to None if no header.
- time_column : Union[int, str], default=0
    Index or name of the time column.
Returns

dict[str, Any] Dictionary containing file information including: - samplerate: Estimated sampling rate in Hz - channels: Number of data channels (excluding time column) - frames: Total number of frames - format: "CSV" - duration: Duration in seconds (or None if cannot be calculated) - ch_labels: List of channel labels

Notes

This method accepts CSV-specific parameters through kwargs. See CSVFileInfoParams for supported parameter types.

Source code in wandas/io/readers.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
@classmethod
def get_file_info(
    cls,
    path: str | Path,
    **kwargs: Any,
) -> dict[str, Any]:
    """Get basic information about the CSV file.

    Parameters
    ----------
    path : Union[str, Path]
        Path to the CSV file.
    **kwargs : Any
        Additional parameters for CSV reading. Supported parameters:

        - delimiter : str, default=","
            Delimiter character.
        - header : Optional[int], default=0
            Row number to use as header. Set to None if no header.
        - time_column : Union[int, str], default=0
            Index or name of the time column.

    Returns
    -------
    dict[str, Any]
        Dictionary containing file information including:
        - samplerate: Estimated sampling rate in Hz
        - channels: Number of data channels (excluding time column)
        - frames: Total number of frames
        - format: "CSV"
        - duration: Duration in seconds (or None if cannot be calculated)
        - ch_labels: List of channel labels

    Notes
    -----
    This method accepts CSV-specific parameters through kwargs.
    See CSVFileInfoParams for supported parameter types.
    """
    # Extract parameters with defaults
    delimiter: str = kwargs.get("delimiter", ",")
    header: int | None = kwargs.get("header", 0)
    time_column: int | str = kwargs.get("time_column", 0)

    # Read first few lines to determine structure
    df = pd.read_csv(path, delimiter=delimiter, header=header)

    # Estimate sampling rate from first column (assuming it's time)
    try:
        # Get time column as Series
        if isinstance(time_column, str):
            time_series = df[time_column]
        else:
            time_series = df.iloc[:, time_column]
        time_values = np.array(time_series.values)
        if len(time_values) > 1:
            # Use round() instead of int() to handle floating-point precision issues
            estimated_sr = round(1 / np.mean(np.diff(time_values)))
        else:
            estimated_sr = 0  # Cannot determine from single row
    except Exception:
        estimated_sr = 0  # Default if can't calculate

    frames = df.shape[0]
    duration = frames / estimated_sr if estimated_sr > 0 else None

    # Return file info
    return {
        "samplerate": estimated_sr,
        "channels": df.shape[1] - 1,  # Assuming first column is time
        "frames": frames,
        "format": "CSV",
        "duration": duration,
        "ch_labels": df.columns[1:].tolist(),  # Assuming first column is time
    }
get_data(path, channels, start_idx, frames, **kwargs) classmethod

Read data from the CSV file.

Parameters

path : Union[str, Path] Path to the CSV file. channels : list[int] List of channel indices to read. start_idx : int Starting frame index. frames : int Number of frames to read. **kwargs : Any Additional parameters for CSV reading. Supported parameters:

- delimiter : str, default=","
    Delimiter character.
- header : Optional[int], default=0
    Row number to use as header.
- time_column : Union[int, str], default=0
    Index or name of the time column.
Returns

ArrayLike Array of shape (channels, frames) containing the data.

Notes

This method accepts CSV-specific parameters through kwargs. See CSVGetDataParams for supported parameter types.

Source code in wandas/io/readers.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
@classmethod
def get_data(
    cls,
    path: str | Path,
    channels: list[int],
    start_idx: int,
    frames: int,
    **kwargs: Any,
) -> ArrayLike:
    """Read data from the CSV file.

    Parameters
    ----------
    path : Union[str, Path]
        Path to the CSV file.
    channels : list[int]
        List of channel indices to read.
    start_idx : int
        Starting frame index.
    frames : int
        Number of frames to read.
    **kwargs : Any
        Additional parameters for CSV reading. Supported parameters:

        - delimiter : str, default=","
            Delimiter character.
        - header : Optional[int], default=0
            Row number to use as header.
        - time_column : Union[int, str], default=0
            Index or name of the time column.

    Returns
    -------
    ArrayLike
        Array of shape (channels, frames) containing the data.

    Notes
    -----
    This method accepts CSV-specific parameters through kwargs.
    See CSVGetDataParams for supported parameter types.
    """
    # Extract parameters with defaults
    time_column: int | str = kwargs.get("time_column", 0)
    delimiter: str = kwargs.get("delimiter", ",")
    header: int | None = kwargs.get("header", 0)

    logger.debug(f"Reading CSV data from {path} starting at {start_idx}")

    # Read the CSV file
    df = pd.read_csv(path, delimiter=delimiter, header=header)

    # Remove time column
    df = df.drop(
        columns=[time_column]
        if isinstance(time_column, str)
        else df.columns[time_column]
    )

    # Select requested channels - adjust indices to account for time column removal
    if channels:
        try:
            data_df = df.iloc[:, channels]
        except IndexError:
            raise ValueError(f"Requested channels {channels} out of range")
    else:
        data_df = df

    # Handle start_idx and frames for partial reading
    end_idx = start_idx + frames if frames > 0 else None
    data_df = data_df.iloc[start_idx:end_idx]

    # Convert to numpy array and transpose to (channels, samples) format
    result = data_df.values.T

    if not isinstance(result, np.ndarray):
        raise ValueError("Unexpected data type after reading file")

    _shape = result.shape
    logger.debug(f"CSV read complete, returning data with shape {_shape}")
    return result
Functions
get_file_reader(path)

Get an appropriate file reader for the given path.

Source code in wandas/io/readers.py
324
325
326
327
328
329
330
331
332
333
334
335
336
def get_file_reader(path: str | Path) -> FileReader:
    """Get an appropriate file reader for the given path."""
    path_str = str(path)
    ext = Path(path).suffix.lower()

    # Try each reader in order
    for reader in _file_readers:
        if ext in reader.__class__.supported_extensions:
            logger.debug(f"Using {reader.__class__.__name__} for {path_str}")
            return reader

    # If no reader found, raise error
    raise ValueError(f"No suitable file reader found for {path_str}")
register_file_reader(reader_class)

Register a new file reader.

Source code in wandas/io/readers.py
339
340
341
342
343
def register_file_reader(reader_class: type) -> None:
    """Register a new file reader."""
    reader = reader_class()
    _file_readers.append(reader)
    logger.debug(f"Registered new file reader: {reader_class.__name__}")

wav_io

Attributes
logger = logging.getLogger(__name__) module-attribute
Classes
Functions
read_wav(filename, labels=None)

Read a WAV file and create a ChannelFrame object.

Parameters

filename : str Path to the WAV file or URL to the WAV file. labels : list of str, optional Labels for each channel.

Returns

ChannelFrame ChannelFrame object containing the audio data.

Source code in wandas/io/wav_io.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def read_wav(filename: str, labels: list[str] | None = None) -> "ChannelFrame":
    """
    Read a WAV file and create a ChannelFrame object.

    Parameters
    ----------
    filename : str
        Path to the WAV file or URL to the WAV file.
    labels : list of str, optional
        Labels for each channel.

    Returns
    -------
    ChannelFrame
        ChannelFrame object containing the audio data.
    """
    from wandas.frames.channel import ChannelFrame

    # ファイル名がURLかどうかを判断
    if filename.startswith("http://") or filename.startswith("https://"):
        # URLの場合、requestsを使用してダウンロード

        response = requests.get(filename)
        file_obj = io.BytesIO(response.content)
        file_label = os.path.basename(filename)
        # メモリマッピングは使用せずに読み込む
        sampling_rate, data = wavfile.read(file_obj)
    else:
        # ローカルファイルパスの場合
        file_label = os.path.basename(filename)
        # データの読み込み(メモリマッピングを使用)
        sampling_rate, data = wavfile.read(filename, mmap=True)

    # データを(num_channels, num_samples)形状のNumPy配列に変換
    if data.ndim == 1:
        # モノラル:(samples,) -> (1, samples)
        data = np.expand_dims(data, axis=0)
    else:
        # ステレオ:(samples, channels) -> (channels, samples)
        data = data.T

    # NumPy配列からChannelFrameを作成
    channel_frame = ChannelFrame.from_numpy(
        data=data,
        sampling_rate=sampling_rate,
        label=file_label,
        ch_labels=labels,
    )

    return channel_frame
write_wav(filename, target, format=None)

Write a ChannelFrame object to a WAV file.

Parameters

filename : str Path to the WAV file. target : ChannelFrame ChannelFrame object containing the data to write. format : str, optional File format. If None, determined from file extension.

Raises

ValueError If target is not a ChannelFrame object.

Source code in wandas/io/wav_io.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def write_wav(filename: str, target: "ChannelFrame", format: str | None = None) -> None:
    """
    Write a ChannelFrame object to a WAV file.

    Parameters
    ----------
    filename : str
        Path to the WAV file.
    target : ChannelFrame
        ChannelFrame object containing the data to write.
    format : str, optional
        File format. If None, determined from file extension.

    Raises
    ------
    ValueError
        If target is not a ChannelFrame object.
    """
    from wandas.frames.channel import ChannelFrame

    if not isinstance(target, ChannelFrame):
        raise ValueError("target must be a ChannelFrame object.")

    logger.debug(f"Saving audio data to file: {filename} (will compute now)")
    data = target.compute()
    data = data.T
    if data.shape[1] == 1:
        data = data.squeeze(axis=1)
    if data.dtype == float and max([np.abs(data.max()), np.abs(data.min())]) < 1:
        sf.write(
            str(filename),
            data,
            int(target.sampling_rate),
            subtype="FLOAT",
            format=format,
        )
    else:
        sf.write(str(filename), data, int(target.sampling_rate), format=format)
    logger.debug(f"Save complete: {filename}")

wdf_io

WDF (Wandas Data File) I/O module for saving and loading ChannelFrame objects.

This module provides functionality to save and load ChannelFrame objects in the WDF (Wandas Data File) format, which is based on HDF5. The format preserves all metadata including sampling rate, channel labels, units, and frame metadata.

Attributes
da_from_array = da.from_array module-attribute
logger = logging.getLogger(__name__) module-attribute
WDF_FORMAT_VERSION = '0.1' module-attribute
Classes
Functions
save(frame, path, *, format='hdf5', compress='gzip', overwrite=False, dtype=None)

Save a frame to a file.

Parameters:

Name Type Description Default
frame BaseFrame[Any]

The frame to save.

required
path str | Path

Path to save the file. '.wdf' extension will be added if not present.

required
format str

Format to use (currently only 'hdf5' is supported)

'hdf5'
compress str | None

Compression method ('gzip' by default, None for no compression)

'gzip'
overwrite bool

Whether to overwrite existing file

False
dtype str | dtype[Any] | None

Optional data type conversion before saving (e.g. 'float32')

None

Raises:

Type Description
FileExistsError

If the file exists and overwrite=False.

NotImplementedError

For unsupported formats.

Source code in wandas/io/wdf_io.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def save(
    frame: BaseFrame[Any],
    path: str | Path,
    *,
    format: str = "hdf5",
    compress: str | None = "gzip",
    overwrite: bool = False,
    dtype: str | np.dtype[Any] | None = None,
) -> None:
    """Save a frame to a file.

    Args:
        frame: The frame to save.
        path: Path to save the file. '.wdf' extension will be added if not present.
        format: Format to use (currently only 'hdf5' is supported)
        compress: Compression method ('gzip' by default, None for no compression)
        overwrite: Whether to overwrite existing file
        dtype: Optional data type conversion before saving (e.g. 'float32')

    Raises:
        FileExistsError: If the file exists and overwrite=False.
        NotImplementedError: For unsupported formats.
    """
    # Handle path
    path = Path(path)
    if path.suffix != ".wdf":
        path = path.with_suffix(".wdf")

    # Check if file exists
    if path.exists() and not overwrite:
        raise FileExistsError(
            f"File {path} already exists. Set overwrite=True to overwrite."
        )

    # Currently only HDF5 is supported
    if format.lower() != "hdf5":
        raise NotImplementedError(
            f"Format {format} not supported. Only 'hdf5' is currently implemented."
        )

    # Compute data arrays (this triggers actual computation)
    logger.info("Computing data arrays for saving...")
    computed_data = frame.compute()
    if dtype is not None:
        computed_data = computed_data.astype(dtype)

    # Create file
    logger.info(f"Creating HDF5 file at {path}...")
    with h5py.File(path, "w") as f:
        # Set file version
        f.attrs["version"] = WDF_FORMAT_VERSION

        # Store frame metadata
        f.attrs["sampling_rate"] = frame.sampling_rate
        f.attrs["label"] = frame.label or ""
        f.attrs["frame_type"] = type(frame).__name__

        # Create channels group
        channels_grp = f.create_group("channels")

        # Store each channel
        for i, (channel_data, ch_meta) in enumerate(
            zip(computed_data, frame._channel_metadata)
        ):
            ch_grp = channels_grp.create_group(f"{i}")

            # Store channel data
            if compress:
                ch_grp.create_dataset("data", data=channel_data, compression=compress)
            else:
                ch_grp.create_dataset("data", data=channel_data)

            # Store metadata
            ch_grp.attrs["label"] = ch_meta.label
            ch_grp.attrs["unit"] = ch_meta.unit

            # Store extra metadata as JSON
            if ch_meta.extra:
                ch_grp.attrs["metadata_json"] = json.dumps(ch_meta.extra)

        # Store operation history
        if frame.operation_history:
            op_grp = f.create_group("operation_history")
            for i, op in enumerate(frame.operation_history):
                op_sub_grp = op_grp.create_group(f"operation_{i}")
                for k, v in op.items():
                    # Store simple attributes directly
                    if isinstance(v, str | int | float | bool | np.number):
                        op_sub_grp.attrs[k] = v
                    else:
                        # For complex types, serialize to JSON
                        try:
                            op_sub_grp.attrs[k] = json.dumps(v)
                        except (TypeError, OverflowError) as e:
                            logger.warning(
                                f"Could not serialize operation key '{k}': {e}"
                            )
                            op_sub_grp.attrs[k] = str(v)

        # Store frame metadata
        if frame.metadata:
            meta_grp = f.create_group("meta")
            # Store metadata as JSON
            meta_grp.attrs["json"] = json.dumps(frame.metadata)

            # Also store individual metadata items as attributes for compatibility
            for k, v in frame.metadata.items():
                if isinstance(v, str | int | float | bool | np.number):
                    meta_grp.attrs[k] = v

    logger.info(f"Frame saved to {path}")
load(path, *, format='hdf5')

Load a ChannelFrame object from a WDF (Wandas Data File) file.

Parameters:

Name Type Description Default
path str | Path

Path to the WDF file to load.

required
format str

Format of the file. Currently only "hdf5" is supported.

'hdf5'

Returns:

Type Description
ChannelFrame

A new ChannelFrame object with data and metadata loaded from the file.

Raises:

Type Description
FileNotFoundError

If the file doesn't exist.

NotImplementedError

If format is not "hdf5".

ValueError

If the file format is invalid or incompatible.

Example

cf = ChannelFrame.load("audio_data.wdf")

Source code in wandas/io/wdf_io.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def load(path: str | Path, *, format: str = "hdf5") -> "ChannelFrame":
    """Load a ChannelFrame object from a WDF (Wandas Data File) file.

    Args:
        path: Path to the WDF file to load.
        format: Format of the file. Currently only "hdf5" is supported.

    Returns:
        A new ChannelFrame object with data and metadata loaded from the file.

    Raises:
        FileNotFoundError: If the file doesn't exist.
        NotImplementedError: If format is not "hdf5".
        ValueError: If the file format is invalid or incompatible.

    Example:
        >>> cf = ChannelFrame.load("audio_data.wdf")
    """
    # Ensure ChannelFrame is imported here to avoid circular imports
    from ..core.metadata import ChannelMetadata
    from ..frames.channel import ChannelFrame

    if format != "hdf5":
        raise NotImplementedError(f"Format '{format}' is not supported")

    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f"File not found: {path}")

    logger.debug(f"Loading ChannelFrame from {path}")

    with h5py.File(path, "r") as f:
        # Check format version for compatibility
        version = f.attrs.get("version", "unknown")
        if version != WDF_FORMAT_VERSION:
            logger.warning(
                f"File format version mismatch: file={version}, current={WDF_FORMAT_VERSION}"  # noqa: E501
            )

        # Get global attributes
        sampling_rate = float(f.attrs["sampling_rate"])
        frame_label = f.attrs.get("label", "")

        # Get frame metadata
        frame_metadata = {}
        if "meta" in f:
            meta_json = f["meta"].attrs.get("json", "{}")
            frame_metadata = json.loads(meta_json)

        # Load operation history
        operation_history = []
        if "operation_history" in f:
            op_grp = f["operation_history"]
            # Sort operation indices numerically
            op_indices = sorted([int(key.split("_")[1]) for key in op_grp.keys()])

            for idx in op_indices:
                op_sub_grp = op_grp[f"operation_{idx}"]
                op_dict = {}
                for attr_name in op_sub_grp.attrs:
                    attr_value = op_sub_grp.attrs[attr_name]
                    # Try to deserialize JSON, fallback to string
                    try:
                        op_dict[attr_name] = json.loads(attr_value)
                    except (json.JSONDecodeError, TypeError):
                        op_dict[attr_name] = attr_value
                operation_history.append(op_dict)

        # Load channel data and metadata
        all_channel_data = []
        channel_metadata_list = []

        if "channels" in f:
            channels_group = f["channels"]
            # Sort channel indices numerically
            channel_indices = sorted([int(key) for key in channels_group.keys()])

            for idx in channel_indices:
                ch_group = channels_group[f"{idx}"]

                # Load channel data
                channel_data = ch_group["data"][()]

                # Append to combined array
                all_channel_data.append(channel_data)

                # Load channel metadata
                label = ch_group.attrs.get("label", f"Ch{idx}")
                unit = ch_group.attrs.get("unit", "")

                # Load additional metadata if present
                ch_extra = {}
                if "metadata_json" in ch_group.attrs:
                    ch_extra = json.loads(ch_group.attrs["metadata_json"])

                # Create ChannelMetadata object
                channel_metadata = ChannelMetadata(
                    label=label, unit=unit, extra=ch_extra
                )
                channel_metadata_list.append(channel_metadata)

        # Stack channel data into a single array
        if all_channel_data:
            combined_data = np.stack(all_channel_data, axis=0)
        else:
            raise ValueError("No channel data found in the file")

        # Create a new ChannelFrame
        dask_data = da_from_array(combined_data)

        cf = ChannelFrame(
            data=dask_data,
            sampling_rate=sampling_rate,
            label=frame_label if frame_label else None,
            metadata=frame_metadata,
            operation_history=operation_history,
            channel_metadata=channel_metadata_list,
        )

        logger.debug(
            f"ChannelFrame loaded from {path}: {len(cf)} channels, {cf.n_samples} samples"  # noqa: E501
        )
        return cf

ユーティリティモジュール

ユーティリティモジュールは補助機能を提供します。

wandas.utils

Attributes

__all__ = ['filter_kwargs', 'accepted_kwargs', 'validate_sampling_rate'] module-attribute

Functions

accepted_kwargs(func)

Get the set of explicit keyword arguments accepted by a function and whether it accepts **kwargs.

Parameters:

Name Type Description Default
func Callable[..., Any]

The function to inspect.

required

Returns:

Type Description
set[str]

A tuple containing:

bool
  • set[str]: Set of explicit keyword argument names accepted by func.
tuple[set[str], bool]
  • bool: Whether the function accepts variable keyword arguments (**kwargs).
Source code in wandas/utils/introspection.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def accepted_kwargs(func: Callable[..., Any]) -> tuple[set[str], bool]:
    """
    Get the set of explicit keyword arguments accepted by
    a function and whether it accepts **kwargs.

    Args:
        func: The function to inspect.

    Returns:
        A tuple containing:
        - set[str]: Set of explicit keyword argument names accepted by func.
        - bool: Whether the function accepts variable keyword arguments (**kwargs).
    """
    # モックオブジェクトの場合は空セットと無制限フラグを返す
    if hasattr(func, "__module__") and func.__module__ == "unittest.mock":
        return set(), True
    try:
        params = signature(func).parameters.values()

        # 明示的に定義されている引数を収集
        explicit_kwargs = {
            p.name
            for p in params
            if p.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
        }

        # **kwargsを受け付けるかどうかのフラグ
        has_var_kwargs = any(p.kind is Parameter.VAR_KEYWORD for p in params)

        return explicit_kwargs, has_var_kwargs
    except (ValueError, TypeError):
        # シグネチャを取得できない場合は空セットと無制限フラグを返す
        return set(), True

filter_kwargs(func, kwargs, *, strict_mode=False)

Filter keyword arguments to only those accepted by the function.

This function examines the signature of func and returns a dictionary containing only the key-value pairs from kwargs that are valid keyword arguments for func.

Parameters:

Name Type Description Default
func Callable[..., Any]

The function to filter keyword arguments for.

required
kwargs Mapping[str, Any]

The keyword arguments to filter.

required
strict_mode bool

If True, only explicitly defined parameters are passed even when the function accepts kwargs. If False (default), all parameters are passed to functions that accept kwargs, but a warning is issued for parameters not explicitly defined.

False

Returns:

Type Description
dict[str, Any]

A dictionary containing only the key-value pairs that are valid for func.

Source code in wandas/utils/introspection.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def filter_kwargs(
    func: Callable[..., Any],
    kwargs: Mapping[str, Any],
    *,
    strict_mode: bool = False,
) -> dict[str, Any]:
    """
    Filter keyword arguments to only those accepted by the function.

    This function examines the signature of `func` and returns a dictionary
    containing only the key-value pairs from `kwargs` that are valid keyword
    arguments for `func`.

    Args:
        func: The function to filter keyword arguments for.
        kwargs: The keyword arguments to filter.
        strict_mode: If True, only explicitly defined parameters are passed even when
            the function accepts **kwargs. If False (default), all parameters are
            passed to functions that accept **kwargs, but a warning is issued for
            parameters not explicitly defined.

    Returns:
        A dictionary containing only the key-value pairs that are valid for `func`.
    """
    explicit_params, accepts_var_kwargs = accepted_kwargs(func)

    # **kwargsを受け付けない場合、または strict_mode が True の場合は、
    # 明示的なパラメータのみをフィルタリング
    if not accepts_var_kwargs or strict_mode:
        filtered = {k: v for k, v in kwargs.items() if k in explicit_params}
        return filtered

    # **kwargsを受け付ける場合(strict_modeがFalseの場合)は全キーを許可
    # ただし、明示的に定義されていないキーには警告を出す
    unknown = set(kwargs) - explicit_params
    if unknown:
        warnings.warn(
            f"Implicit kwargs for {func.__name__}: {unknown}",
            UserWarning,
            stacklevel=2,
        )
    return dict(kwargs)

validate_sampling_rate(sampling_rate, param_name='sampling_rate')

Validate that sampling rate is positive.

Parameters

sampling_rate : float Sampling rate in Hz to validate. param_name : str, default="sampling_rate" Name of the parameter being validated (for error messages).

Raises

ValueError If sampling_rate is not positive (i.e., <= 0).

Examples

validate_sampling_rate(44100) # No error validate_sampling_rate(0) # Raises ValueError validate_sampling_rate(-100) # Raises ValueError

Source code in wandas/utils/util.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def validate_sampling_rate(
    sampling_rate: float, param_name: str = "sampling_rate"
) -> None:
    """
    Validate that sampling rate is positive.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate in Hz to validate.
    param_name : str, default="sampling_rate"
        Name of the parameter being validated (for error messages).

    Raises
    ------
    ValueError
        If sampling_rate is not positive (i.e., <= 0).

    Examples
    --------
    >>> validate_sampling_rate(44100)  # No error
    >>> validate_sampling_rate(0)  # Raises ValueError
    >>> validate_sampling_rate(-100)  # Raises ValueError
    """
    if sampling_rate <= 0:
        raise ValueError(
            f"Invalid {param_name}\n"
            f"  Got: {sampling_rate} Hz\n"
            f"  Expected: Positive value > 0\n"
            f"Sampling rate represents samples per second and must be positive.\n"
            f"Common values: 8000, 16000, 22050, 44100, 48000 Hz"
        )

Modules

frame_dataset

Attributes
logger = logging.getLogger(__name__) module-attribute
FrameType = ChannelFrame | SpectrogramFrame module-attribute
F = TypeVar('F', bound=FrameType) module-attribute
F_out = TypeVar('F_out', bound=FrameType) module-attribute
Classes
LazyFrame dataclass

Bases: Generic[F]

A class that encapsulates a frame and its loading state.

Attributes:

Name Type Description
file_path Path

File path associated with the frame

frame F | None

Loaded frame object (None if not loaded)

is_loaded bool

Flag indicating if the frame is loaded

load_attempted bool

Flag indicating if loading was attempted (for error detection)

Source code in wandas/utils/frame_dataset.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@dataclass
class LazyFrame(Generic[F]):
    """
    A class that encapsulates a frame and its loading state.

    Attributes:
        file_path: File path associated with the frame
        frame: Loaded frame object (None if not loaded)
        is_loaded: Flag indicating if the frame is loaded
        load_attempted: Flag indicating if loading was attempted (for error detection)
    """

    file_path: Path
    frame: F | None = None
    is_loaded: bool = False
    load_attempted: bool = False

    def ensure_loaded(self, loader: Callable[[Path], F | None]) -> F | None:
        """
        Ensures the frame is loaded, loading it if necessary.

        Args:
            loader: Function to load a frame from a file path

        Returns:
            The loaded frame, or None if loading failed
        """
        # Return the current frame if already loaded
        if self.is_loaded:
            return self.frame

        # Attempt to load if not loaded yet
        try:
            self.load_attempted = True
            self.frame = loader(self.file_path)
            self.is_loaded = True
            return self.frame
        except Exception as e:
            logger.error(f"Failed to load file {self.file_path}: {str(e)}")
            self.is_loaded = True  # Loading was attempted
            self.frame = None
            return None

    def reset(self) -> None:
        """
        Reset the frame state.
        """
        self.frame = None
        self.is_loaded = False
        self.load_attempted = False
Attributes
file_path instance-attribute
frame = None class-attribute instance-attribute
is_loaded = False class-attribute instance-attribute
load_attempted = False class-attribute instance-attribute
Functions
__init__(file_path, frame=None, is_loaded=False, load_attempted=False)
ensure_loaded(loader)

Ensures the frame is loaded, loading it if necessary.

Parameters:

Name Type Description Default
loader Callable[[Path], F | None]

Function to load a frame from a file path

required

Returns:

Type Description
F | None

The loaded frame, or None if loading failed

Source code in wandas/utils/frame_dataset.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def ensure_loaded(self, loader: Callable[[Path], F | None]) -> F | None:
    """
    Ensures the frame is loaded, loading it if necessary.

    Args:
        loader: Function to load a frame from a file path

    Returns:
        The loaded frame, or None if loading failed
    """
    # Return the current frame if already loaded
    if self.is_loaded:
        return self.frame

    # Attempt to load if not loaded yet
    try:
        self.load_attempted = True
        self.frame = loader(self.file_path)
        self.is_loaded = True
        return self.frame
    except Exception as e:
        logger.error(f"Failed to load file {self.file_path}: {str(e)}")
        self.is_loaded = True  # Loading was attempted
        self.frame = None
        return None
reset()

Reset the frame state.

Source code in wandas/utils/frame_dataset.py
65
66
67
68
69
70
71
def reset(self) -> None:
    """
    Reset the frame state.
    """
    self.frame = None
    self.is_loaded = False
    self.load_attempted = False
FrameDataset

Bases: Generic[F], ABC

Abstract base dataset class for processing files in a folder. Includes lazy loading capability to efficiently handle large datasets. Subclasses handle specific frame types (ChannelFrame, SpectrogramFrame, etc.).

Source code in wandas/utils/frame_dataset.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
class FrameDataset(Generic[F], ABC):
    """
    Abstract base dataset class for processing files in a folder.
    Includes lazy loading capability to efficiently handle large datasets.
    Subclasses handle specific frame types (ChannelFrame, SpectrogramFrame, etc.).
    """

    def __init__(
        self,
        folder_path: str,
        sampling_rate: int | None = None,
        signal_length: int | None = None,
        file_extensions: list[str] | None = None,
        lazy_loading: bool = True,
        recursive: bool = False,
        source_dataset: Optional["FrameDataset[Any]"] = None,
        transform: Callable[[Any], F | None] | None = None,
    ):
        self.folder_path = Path(folder_path)
        if source_dataset is None and not self.folder_path.exists():
            raise FileNotFoundError(f"Folder does not exist: {self.folder_path}")

        self.sampling_rate = sampling_rate
        self.signal_length = signal_length
        self.file_extensions = file_extensions or [".wav"]
        self._recursive = recursive
        self._lazy_loading = lazy_loading

        # Changed to a list of LazyFrame
        self._lazy_frames: list[LazyFrame[F]] = []

        self._source_dataset = source_dataset
        self._transform = transform

        if self._source_dataset:
            self._initialize_from_source()
        else:
            self._initialize_from_folder()

    def _initialize_from_source(self) -> None:
        """Initialize from a source dataset."""
        if self._source_dataset is None:
            return

        # Copy file paths from source
        file_paths = self._source_dataset._get_file_paths()
        self._lazy_frames = [LazyFrame(file_path) for file_path in file_paths]

        # Inherit other properties
        self.sampling_rate = self.sampling_rate or self._source_dataset.sampling_rate
        self.signal_length = self.signal_length or self._source_dataset.signal_length
        self.file_extensions = (
            self.file_extensions or self._source_dataset.file_extensions
        )
        self._recursive = self._source_dataset._recursive
        self.folder_path = self._source_dataset.folder_path

    def _initialize_from_folder(self) -> None:
        """Initialize from a folder."""
        self._discover_files()
        if not self._lazy_loading:
            self._load_all_files()

    def _discover_files(self) -> None:
        """Discover files in the folder and store them in a list of LazyFrame."""
        file_paths = []
        for ext in self.file_extensions:
            pattern = f"**/*{ext}" if self._recursive else f"*{ext}"
            file_paths.extend(
                sorted(p for p in self.folder_path.glob(pattern) if p.is_file())
            )

        # Remove duplicates and sort
        file_paths = sorted(list(set(file_paths)))

        # Create a list of LazyFrame
        self._lazy_frames = [LazyFrame(file_path) for file_path in file_paths]

    def _load_all_files(self) -> None:
        """Load all files."""
        for i in tqdm(range(len(self._lazy_frames)), desc="Loading/transforming"):
            try:
                self._ensure_loaded(i)
            except Exception as e:
                filepath = self._lazy_frames[i].file_path
                logger.warning(
                    f"Failed to load/transform index {i} ({filepath}): {str(e)}"
                )
        self._lazy_loading = False

    @abstractmethod
    def _load_file(self, file_path: Path) -> F | None:
        """Abstract method to load a frame from a file."""
        pass

    def _load_from_source(self, index: int) -> F | None:
        """Load a frame from the source dataset and transform it if necessary."""
        if self._source_dataset is None or self._transform is None:
            return None

        source_frame = self._source_dataset._ensure_loaded(index)
        if source_frame is None:
            return None

        try:
            return self._transform(source_frame)
        except Exception as e:
            logger.warning(f"Failed to transform index {index}: {str(e)}")
            return None

    def _ensure_loaded(self, index: int) -> F | None:
        """Ensure the frame at the given index is loaded."""
        if not (0 <= index < len(self._lazy_frames)):
            raise IndexError(
                f"Index {index} is out of range (0-{len(self._lazy_frames) - 1})"
            )

        lazy_frame = self._lazy_frames[index]

        # Return if already loaded
        if lazy_frame.is_loaded:
            return lazy_frame.frame

        try:
            # Convert from source dataset
            if self._transform and self._source_dataset:
                lazy_frame.load_attempted = True
                frame = self._load_from_source(index)
                lazy_frame.frame = frame
                lazy_frame.is_loaded = True
                return frame
            # Load directly from file
            else:
                return lazy_frame.ensure_loaded(self._load_file)
        except Exception as e:
            f_path = lazy_frame.file_path
            logger.error(
                f"Failed to load or initialize index {index} ({f_path}): {str(e)}"
            )
            lazy_frame.frame = None
            lazy_frame.is_loaded = True
            lazy_frame.load_attempted = True
            return None

    def _get_file_paths(self) -> list[Path]:
        """Get a list of file paths."""
        return [lazy_frame.file_path for lazy_frame in self._lazy_frames]

    def __len__(self) -> int:
        """Return the number of files in the dataset."""
        return len(self._lazy_frames)

    def get_by_label(self, label: str) -> F | None:
        """
        Get a frame by its label (filename).

        Parameters
        ----------
        label : str
            The filename (label) to search for (e.g., 'sample_1.wav').

        Returns
        -------
        Optional[F]
            The frame if found, otherwise None.

        Examples
        --------
        >>> frame = dataset.get_by_label("sample_1.wav")
        >>> if frame:
        ...     print(frame.label)
        """
        # Keep for backward compatibility: return the first match but emit
        # a DeprecationWarning recommending `get_all_by_label`.
        all_matches = self.get_all_by_label(label)
        if len(all_matches) > 0:
            warnings.warn(
                "get_by_label() returns the first matching frame and is deprecated; "
                "use get_all_by_label() to obtain all matches.",
                DeprecationWarning,
                stacklevel=2,
            )
            return all_matches[0]
        return None

    def get_all_by_label(self, label: str) -> list[F]:
        """
        Get all frames matching the given label (filename).

        Parameters
        ----------
        label : str
            The filename (label) to search for (e.g., 'sample_1.wav').

        Returns
        -------
        list[F]
            A list of frames matching the label.
            If none are found, returns an empty list.

        Notes
        -----
        - Search is performed against the filename portion only (i.e. Path.name).
        - Each matched frame will be loaded (triggering lazy load) via `_ensure_loaded`.
        """
        matches: list[F] = []
        for i, lazy_frame in enumerate(self._lazy_frames):
            if lazy_frame.file_path.name == label:
                loaded = self._ensure_loaded(i)
                if loaded is not None:
                    matches.append(loaded)
        return matches

    @overload
    def __getitem__(self, key: int) -> F | None: ...

    @overload
    def __getitem__(self, key: str) -> list[F]: ...

    def __getitem__(self, key: int | str) -> F | None | list[F]:
        """
        Get the frame by index (int) or label (str).

        Parameters
        ----------
        key : int or str
            Index (int) or filename/label (str).

        Returns
        -------
        Optional[F] or list[F]
            If `key` is an int, returns the frame or None. If `key` is a str,
            returns a list of matching frames (may be empty).

        Examples
        --------
        >>> frame = dataset[0]  # by index
        >>> frames = dataset["sample_1.wav"]  # list of matches by filename
        """
        if isinstance(key, int):
            return self._ensure_loaded(key)
        if isinstance(key, str):
            # pandas-like behaviour: return all matches for the label as a list
            return self.get_all_by_label(key)
        raise TypeError(f"Invalid key type: {type(key)}. Must be int or str.")

    @overload
    def apply(self, func: Callable[[F], F_out | None]) -> "FrameDataset[F_out]": ...

    @overload
    def apply(self, func: Callable[[F], Any | None]) -> "FrameDataset[Any]": ...

    def apply(self, func: Callable[[F], Any | None]) -> "FrameDataset[Any]":
        """Apply a function to the entire dataset to create a new dataset."""
        new_dataset = type(self)(
            folder_path=str(self.folder_path),
            lazy_loading=True,
            source_dataset=self,
            transform=func,
            sampling_rate=self.sampling_rate,
            signal_length=self.signal_length,
            file_extensions=self.file_extensions,
            recursive=self._recursive,
        )
        return cast("FrameDataset[Any]", new_dataset)

    def save(self, output_folder: str, filename_prefix: str = "") -> None:
        """Save processed frames to files."""
        raise NotImplementedError("The save method is not currently implemented.")

    def sample(
        self,
        n: int | None = None,
        ratio: float | None = None,
        seed: int | None = None,
    ) -> "FrameDataset[F]":
        """Get a sample from the dataset."""
        if seed is not None:
            random.seed(seed)

        total = len(self._lazy_frames)
        if total == 0:
            return type(self)(
                str(self.folder_path),
                sampling_rate=self.sampling_rate,
                signal_length=self.signal_length,
                file_extensions=self.file_extensions,
                lazy_loading=self._lazy_loading,
                recursive=self._recursive,
            )

        # Determine sample size
        if n is None and ratio is None:
            n = max(1, min(10, int(total * 0.1)))
        elif n is None and ratio is not None:
            n = max(1, int(total * ratio))
        elif n is not None:
            n = max(1, n)
        else:
            n = 1

        n = min(n, total)

        # Randomly select indices
        sampled_indices = sorted(random.sample(range(total), n))

        return _SampledFrameDataset(self, sampled_indices)

    def get_metadata(self) -> dict[str, Any]:
        """Get metadata for the dataset."""
        actual_sr: int | float | None = self.sampling_rate
        frame_type_name = "Unknown"

        # Count loaded frames
        loaded_count = sum(
            1 for lazy_frame in self._lazy_frames if lazy_frame.is_loaded
        )

        # Get metadata from the first frame (if possible)
        first_frame: F | None = None
        if len(self._lazy_frames) > 0:
            try:
                if self._lazy_frames[0].is_loaded:
                    first_frame = self._lazy_frames[0].frame

                if first_frame:
                    actual_sr = getattr(
                        first_frame, "sampling_rate", self.sampling_rate
                    )
                    frame_type_name = type(first_frame).__name__
            except Exception as e:
                logger.warning(
                    f"Error accessing the first frame during metadata retrieval: {e}"
                )

        return {
            "folder_path": str(self.folder_path),
            "file_count": len(self._lazy_frames),
            "loaded_count": loaded_count,
            "target_sampling_rate": self.sampling_rate,
            "actual_sampling_rate": actual_sr,
            "signal_length": self.signal_length,
            "file_extensions": self.file_extensions,
            "lazy_loading": self._lazy_loading,
            "recursive": self._recursive,
            "frame_type": frame_type_name,
            "has_transform": self._transform is not None,
            "is_sampled": isinstance(self, _SampledFrameDataset),
        }
Attributes
folder_path = Path(folder_path) instance-attribute
sampling_rate = sampling_rate instance-attribute
signal_length = signal_length instance-attribute
file_extensions = file_extensions or ['.wav'] instance-attribute
Functions
__init__(folder_path, sampling_rate=None, signal_length=None, file_extensions=None, lazy_loading=True, recursive=False, source_dataset=None, transform=None)
Source code in wandas/utils/frame_dataset.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(
    self,
    folder_path: str,
    sampling_rate: int | None = None,
    signal_length: int | None = None,
    file_extensions: list[str] | None = None,
    lazy_loading: bool = True,
    recursive: bool = False,
    source_dataset: Optional["FrameDataset[Any]"] = None,
    transform: Callable[[Any], F | None] | None = None,
):
    self.folder_path = Path(folder_path)
    if source_dataset is None and not self.folder_path.exists():
        raise FileNotFoundError(f"Folder does not exist: {self.folder_path}")

    self.sampling_rate = sampling_rate
    self.signal_length = signal_length
    self.file_extensions = file_extensions or [".wav"]
    self._recursive = recursive
    self._lazy_loading = lazy_loading

    # Changed to a list of LazyFrame
    self._lazy_frames: list[LazyFrame[F]] = []

    self._source_dataset = source_dataset
    self._transform = transform

    if self._source_dataset:
        self._initialize_from_source()
    else:
        self._initialize_from_folder()
__len__()

Return the number of files in the dataset.

Source code in wandas/utils/frame_dataset.py
222
223
224
def __len__(self) -> int:
    """Return the number of files in the dataset."""
    return len(self._lazy_frames)
get_by_label(label)

Get a frame by its label (filename).

Parameters

label : str The filename (label) to search for (e.g., 'sample_1.wav').

Returns

Optional[F] The frame if found, otherwise None.

Examples

frame = dataset.get_by_label("sample_1.wav") if frame: ... print(frame.label)

Source code in wandas/utils/frame_dataset.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
def get_by_label(self, label: str) -> F | None:
    """
    Get a frame by its label (filename).

    Parameters
    ----------
    label : str
        The filename (label) to search for (e.g., 'sample_1.wav').

    Returns
    -------
    Optional[F]
        The frame if found, otherwise None.

    Examples
    --------
    >>> frame = dataset.get_by_label("sample_1.wav")
    >>> if frame:
    ...     print(frame.label)
    """
    # Keep for backward compatibility: return the first match but emit
    # a DeprecationWarning recommending `get_all_by_label`.
    all_matches = self.get_all_by_label(label)
    if len(all_matches) > 0:
        warnings.warn(
            "get_by_label() returns the first matching frame and is deprecated; "
            "use get_all_by_label() to obtain all matches.",
            DeprecationWarning,
            stacklevel=2,
        )
        return all_matches[0]
    return None
get_all_by_label(label)

Get all frames matching the given label (filename).

Parameters

label : str The filename (label) to search for (e.g., 'sample_1.wav').

Returns

list[F] A list of frames matching the label. If none are found, returns an empty list.

Notes
  • Search is performed against the filename portion only (i.e. Path.name).
  • Each matched frame will be loaded (triggering lazy load) via _ensure_loaded.
Source code in wandas/utils/frame_dataset.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def get_all_by_label(self, label: str) -> list[F]:
    """
    Get all frames matching the given label (filename).

    Parameters
    ----------
    label : str
        The filename (label) to search for (e.g., 'sample_1.wav').

    Returns
    -------
    list[F]
        A list of frames matching the label.
        If none are found, returns an empty list.

    Notes
    -----
    - Search is performed against the filename portion only (i.e. Path.name).
    - Each matched frame will be loaded (triggering lazy load) via `_ensure_loaded`.
    """
    matches: list[F] = []
    for i, lazy_frame in enumerate(self._lazy_frames):
        if lazy_frame.file_path.name == label:
            loaded = self._ensure_loaded(i)
            if loaded is not None:
                matches.append(loaded)
    return matches
__getitem__(key)
__getitem__(key: int) -> F | None
__getitem__(key: str) -> list[F]

Get the frame by index (int) or label (str).

Parameters

key : int or str Index (int) or filename/label (str).

Returns

Optional[F] or list[F] If key is an int, returns the frame or None. If key is a str, returns a list of matching frames (may be empty).

Examples

frame = dataset[0] # by index frames = dataset["sample_1.wav"] # list of matches by filename

Source code in wandas/utils/frame_dataset.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def __getitem__(self, key: int | str) -> F | None | list[F]:
    """
    Get the frame by index (int) or label (str).

    Parameters
    ----------
    key : int or str
        Index (int) or filename/label (str).

    Returns
    -------
    Optional[F] or list[F]
        If `key` is an int, returns the frame or None. If `key` is a str,
        returns a list of matching frames (may be empty).

    Examples
    --------
    >>> frame = dataset[0]  # by index
    >>> frames = dataset["sample_1.wav"]  # list of matches by filename
    """
    if isinstance(key, int):
        return self._ensure_loaded(key)
    if isinstance(key, str):
        # pandas-like behaviour: return all matches for the label as a list
        return self.get_all_by_label(key)
    raise TypeError(f"Invalid key type: {type(key)}. Must be int or str.")
apply(func)
apply(func: Callable[[F], F_out | None]) -> FrameDataset[F_out]
apply(func: Callable[[F], Any | None]) -> FrameDataset[Any]

Apply a function to the entire dataset to create a new dataset.

Source code in wandas/utils/frame_dataset.py
326
327
328
329
330
331
332
333
334
335
336
337
338
def apply(self, func: Callable[[F], Any | None]) -> "FrameDataset[Any]":
    """Apply a function to the entire dataset to create a new dataset."""
    new_dataset = type(self)(
        folder_path=str(self.folder_path),
        lazy_loading=True,
        source_dataset=self,
        transform=func,
        sampling_rate=self.sampling_rate,
        signal_length=self.signal_length,
        file_extensions=self.file_extensions,
        recursive=self._recursive,
    )
    return cast("FrameDataset[Any]", new_dataset)
save(output_folder, filename_prefix='')

Save processed frames to files.

Source code in wandas/utils/frame_dataset.py
340
341
342
def save(self, output_folder: str, filename_prefix: str = "") -> None:
    """Save processed frames to files."""
    raise NotImplementedError("The save method is not currently implemented.")
sample(n=None, ratio=None, seed=None)

Get a sample from the dataset.

Source code in wandas/utils/frame_dataset.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def sample(
    self,
    n: int | None = None,
    ratio: float | None = None,
    seed: int | None = None,
) -> "FrameDataset[F]":
    """Get a sample from the dataset."""
    if seed is not None:
        random.seed(seed)

    total = len(self._lazy_frames)
    if total == 0:
        return type(self)(
            str(self.folder_path),
            sampling_rate=self.sampling_rate,
            signal_length=self.signal_length,
            file_extensions=self.file_extensions,
            lazy_loading=self._lazy_loading,
            recursive=self._recursive,
        )

    # Determine sample size
    if n is None and ratio is None:
        n = max(1, min(10, int(total * 0.1)))
    elif n is None and ratio is not None:
        n = max(1, int(total * ratio))
    elif n is not None:
        n = max(1, n)
    else:
        n = 1

    n = min(n, total)

    # Randomly select indices
    sampled_indices = sorted(random.sample(range(total), n))

    return _SampledFrameDataset(self, sampled_indices)
get_metadata()

Get metadata for the dataset.

Source code in wandas/utils/frame_dataset.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
def get_metadata(self) -> dict[str, Any]:
    """Get metadata for the dataset."""
    actual_sr: int | float | None = self.sampling_rate
    frame_type_name = "Unknown"

    # Count loaded frames
    loaded_count = sum(
        1 for lazy_frame in self._lazy_frames if lazy_frame.is_loaded
    )

    # Get metadata from the first frame (if possible)
    first_frame: F | None = None
    if len(self._lazy_frames) > 0:
        try:
            if self._lazy_frames[0].is_loaded:
                first_frame = self._lazy_frames[0].frame

            if first_frame:
                actual_sr = getattr(
                    first_frame, "sampling_rate", self.sampling_rate
                )
                frame_type_name = type(first_frame).__name__
        except Exception as e:
            logger.warning(
                f"Error accessing the first frame during metadata retrieval: {e}"
            )

    return {
        "folder_path": str(self.folder_path),
        "file_count": len(self._lazy_frames),
        "loaded_count": loaded_count,
        "target_sampling_rate": self.sampling_rate,
        "actual_sampling_rate": actual_sr,
        "signal_length": self.signal_length,
        "file_extensions": self.file_extensions,
        "lazy_loading": self._lazy_loading,
        "recursive": self._recursive,
        "frame_type": frame_type_name,
        "has_transform": self._transform is not None,
        "is_sampled": isinstance(self, _SampledFrameDataset),
    }
ChannelFrameDataset

Bases: FrameDataset[ChannelFrame]

Dataset class for handling audio files as ChannelFrames in a folder.

Source code in wandas/utils/frame_dataset.py
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
class ChannelFrameDataset(FrameDataset[ChannelFrame]):
    """
    Dataset class for handling audio files as ChannelFrames in a folder.
    """

    def __init__(
        self,
        folder_path: str,
        sampling_rate: int | None = None,
        signal_length: int | None = None,
        file_extensions: list[str] | None = None,
        lazy_loading: bool = True,
        recursive: bool = False,
        source_dataset: Optional["FrameDataset[Any]"] = None,
        transform: Callable[[Any], ChannelFrame | None] | None = None,
    ):
        _file_extensions = file_extensions or [
            ".wav",
            ".mp3",
            ".flac",
            ".csv",
        ]

        super().__init__(
            folder_path=folder_path,
            sampling_rate=sampling_rate,
            signal_length=signal_length,
            file_extensions=_file_extensions,
            lazy_loading=lazy_loading,
            recursive=recursive,
            source_dataset=source_dataset,
            transform=transform,
        )

    def _load_file(self, file_path: Path) -> ChannelFrame | None:
        """Load an audio file and return a ChannelFrame."""
        try:
            frame = ChannelFrame.from_file(file_path)
            if self.sampling_rate and frame.sampling_rate != self.sampling_rate:
                logger.info(
                    f"Resampling file {file_path.name} ({frame.sampling_rate} Hz) to "
                    f"dataset rate ({self.sampling_rate} Hz)."
                )
                frame = frame.resampling(target_sr=self.sampling_rate)
            return frame
        except Exception as e:
            logger.error(f"Failed to load or initialize file {file_path}: {str(e)}")
            return None

    def resample(self, target_sr: int) -> "ChannelFrameDataset":
        """Resample all frames in the dataset."""

        def _resample_func(frame: ChannelFrame) -> ChannelFrame | None:
            if frame is None:
                return None
            try:
                return frame.resampling(target_sr=target_sr)
            except Exception as e:
                logger.warning(f"Resampling error (target_sr={target_sr}): {e}")
                return None

        new_dataset = self.apply(_resample_func)
        return cast(ChannelFrameDataset, new_dataset)

    def trim(self, start: float, end: float) -> "ChannelFrameDataset":
        """Trim all frames in the dataset."""

        def _trim_func(frame: ChannelFrame) -> ChannelFrame | None:
            if frame is None:
                return None
            try:
                return frame.trim(start=start, end=end)
            except Exception as e:
                logger.warning(f"Trimming error (start={start}, end={end}): {e}")
                return None

        new_dataset = self.apply(_trim_func)
        return cast(ChannelFrameDataset, new_dataset)

    def normalize(self, **kwargs: Any) -> "ChannelFrameDataset":
        """Normalize all frames in the dataset."""

        def _normalize_func(frame: ChannelFrame) -> ChannelFrame | None:
            if frame is None:
                return None
            try:
                return frame.normalize(**kwargs)
            except Exception as e:
                logger.warning(f"Normalization error ({kwargs}): {e}")
                return None

        new_dataset = self.apply(_normalize_func)
        return cast(ChannelFrameDataset, new_dataset)

    def stft(
        self,
        n_fft: int = 2048,
        hop_length: int | None = None,
        win_length: int | None = None,
        window: str = "hann",
    ) -> "SpectrogramFrameDataset":
        """Apply STFT to all frames in the dataset."""
        _hop = hop_length or n_fft // 4

        def _stft_func(frame: ChannelFrame) -> SpectrogramFrame | None:
            if frame is None:
                return None
            try:
                return frame.stft(
                    n_fft=n_fft,
                    hop_length=_hop,
                    win_length=win_length,
                    window=window,
                )
            except Exception as e:
                logger.warning(f"STFT error (n_fft={n_fft}, hop={_hop}): {e}")
                return None

        new_dataset = SpectrogramFrameDataset(
            folder_path=str(self.folder_path),
            lazy_loading=True,
            source_dataset=self,
            transform=_stft_func,
            sampling_rate=self.sampling_rate,
        )
        return new_dataset

    @classmethod
    def from_folder(
        cls,
        folder_path: str,
        sampling_rate: int | None = None,
        file_extensions: list[str] | None = None,
        recursive: bool = False,
        lazy_loading: bool = True,
    ) -> "ChannelFrameDataset":
        """Class method to create a ChannelFrameDataset from a folder."""
        extensions = (
            file_extensions
            if file_extensions is not None
            else [".wav", ".mp3", ".flac", ".csv"]
        )

        return cls(
            folder_path,
            sampling_rate=sampling_rate,
            file_extensions=extensions,
            lazy_loading=lazy_loading,
            recursive=recursive,
        )
Functions
__init__(folder_path, sampling_rate=None, signal_length=None, file_extensions=None, lazy_loading=True, recursive=False, source_dataset=None, transform=None)
Source code in wandas/utils/frame_dataset.py
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
def __init__(
    self,
    folder_path: str,
    sampling_rate: int | None = None,
    signal_length: int | None = None,
    file_extensions: list[str] | None = None,
    lazy_loading: bool = True,
    recursive: bool = False,
    source_dataset: Optional["FrameDataset[Any]"] = None,
    transform: Callable[[Any], ChannelFrame | None] | None = None,
):
    _file_extensions = file_extensions or [
        ".wav",
        ".mp3",
        ".flac",
        ".csv",
    ]

    super().__init__(
        folder_path=folder_path,
        sampling_rate=sampling_rate,
        signal_length=signal_length,
        file_extensions=_file_extensions,
        lazy_loading=lazy_loading,
        recursive=recursive,
        source_dataset=source_dataset,
        transform=transform,
    )
resample(target_sr)

Resample all frames in the dataset.

Source code in wandas/utils/frame_dataset.py
588
589
590
591
592
593
594
595
596
597
598
599
600
601
def resample(self, target_sr: int) -> "ChannelFrameDataset":
    """Resample all frames in the dataset."""

    def _resample_func(frame: ChannelFrame) -> ChannelFrame | None:
        if frame is None:
            return None
        try:
            return frame.resampling(target_sr=target_sr)
        except Exception as e:
            logger.warning(f"Resampling error (target_sr={target_sr}): {e}")
            return None

    new_dataset = self.apply(_resample_func)
    return cast(ChannelFrameDataset, new_dataset)
trim(start, end)

Trim all frames in the dataset.

Source code in wandas/utils/frame_dataset.py
603
604
605
606
607
608
609
610
611
612
613
614
615
616
def trim(self, start: float, end: float) -> "ChannelFrameDataset":
    """Trim all frames in the dataset."""

    def _trim_func(frame: ChannelFrame) -> ChannelFrame | None:
        if frame is None:
            return None
        try:
            return frame.trim(start=start, end=end)
        except Exception as e:
            logger.warning(f"Trimming error (start={start}, end={end}): {e}")
            return None

    new_dataset = self.apply(_trim_func)
    return cast(ChannelFrameDataset, new_dataset)
normalize(**kwargs)

Normalize all frames in the dataset.

Source code in wandas/utils/frame_dataset.py
618
619
620
621
622
623
624
625
626
627
628
629
630
631
def normalize(self, **kwargs: Any) -> "ChannelFrameDataset":
    """Normalize all frames in the dataset."""

    def _normalize_func(frame: ChannelFrame) -> ChannelFrame | None:
        if frame is None:
            return None
        try:
            return frame.normalize(**kwargs)
        except Exception as e:
            logger.warning(f"Normalization error ({kwargs}): {e}")
            return None

    new_dataset = self.apply(_normalize_func)
    return cast(ChannelFrameDataset, new_dataset)
stft(n_fft=2048, hop_length=None, win_length=None, window='hann')

Apply STFT to all frames in the dataset.

Source code in wandas/utils/frame_dataset.py
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
def stft(
    self,
    n_fft: int = 2048,
    hop_length: int | None = None,
    win_length: int | None = None,
    window: str = "hann",
) -> "SpectrogramFrameDataset":
    """Apply STFT to all frames in the dataset."""
    _hop = hop_length or n_fft // 4

    def _stft_func(frame: ChannelFrame) -> SpectrogramFrame | None:
        if frame is None:
            return None
        try:
            return frame.stft(
                n_fft=n_fft,
                hop_length=_hop,
                win_length=win_length,
                window=window,
            )
        except Exception as e:
            logger.warning(f"STFT error (n_fft={n_fft}, hop={_hop}): {e}")
            return None

    new_dataset = SpectrogramFrameDataset(
        folder_path=str(self.folder_path),
        lazy_loading=True,
        source_dataset=self,
        transform=_stft_func,
        sampling_rate=self.sampling_rate,
    )
    return new_dataset
from_folder(folder_path, sampling_rate=None, file_extensions=None, recursive=False, lazy_loading=True) classmethod

Class method to create a ChannelFrameDataset from a folder.

Source code in wandas/utils/frame_dataset.py
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
@classmethod
def from_folder(
    cls,
    folder_path: str,
    sampling_rate: int | None = None,
    file_extensions: list[str] | None = None,
    recursive: bool = False,
    lazy_loading: bool = True,
) -> "ChannelFrameDataset":
    """Class method to create a ChannelFrameDataset from a folder."""
    extensions = (
        file_extensions
        if file_extensions is not None
        else [".wav", ".mp3", ".flac", ".csv"]
    )

    return cls(
        folder_path,
        sampling_rate=sampling_rate,
        file_extensions=extensions,
        lazy_loading=lazy_loading,
        recursive=recursive,
    )
SpectrogramFrameDataset

Bases: FrameDataset[SpectrogramFrame]

Dataset class for handling spectrogram data as SpectrogramFrames. Expected to be generated mainly as a result of ChannelFrameDataset.stft().

Source code in wandas/utils/frame_dataset.py
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
class SpectrogramFrameDataset(FrameDataset[SpectrogramFrame]):
    """
    Dataset class for handling spectrogram data as SpectrogramFrames.
    Expected to be generated mainly as a result of ChannelFrameDataset.stft().
    """

    def __init__(
        self,
        folder_path: str,
        sampling_rate: int | None = None,
        signal_length: int | None = None,
        file_extensions: list[str] | None = None,
        lazy_loading: bool = True,
        recursive: bool = False,
        source_dataset: Optional["FrameDataset[Any]"] = None,
        transform: Callable[[Any], SpectrogramFrame | None] | None = None,
    ):
        super().__init__(
            folder_path=folder_path,
            sampling_rate=sampling_rate,
            signal_length=signal_length,
            file_extensions=file_extensions,
            lazy_loading=lazy_loading,
            recursive=recursive,
            source_dataset=source_dataset,
            transform=transform,
        )

    def _load_file(self, file_path: Path) -> SpectrogramFrame | None:
        """Direct loading from files is not currently supported."""
        logger.warning(
            "No method defined for directly loading SpectrogramFrames. Normally "
            "created from ChannelFrameDataset.stft()."
        )
        raise NotImplementedError(
            "No method defined for directly loading SpectrogramFrames"
        )

    def plot(self, index: int, **kwargs: Any) -> None:
        """Plot the spectrogram at the specified index."""
        try:
            frame = self._ensure_loaded(index)

            if frame is None:
                logger.warning(
                    f"Cannot plot index {index} as it failed to load/transform."
                )
                return

            plot_method = getattr(frame, "plot", None)
            if callable(plot_method):
                plot_method(**kwargs)
            else:
                logger.warning(
                    f"Frame (index {index}, type {type(frame).__name__}) does not "
                    f"have a plot method implemented."
                )
        except Exception as e:
            logger.error(f"An error occurred while plotting index {index}: {e}")
Functions
__init__(folder_path, sampling_rate=None, signal_length=None, file_extensions=None, lazy_loading=True, recursive=False, source_dataset=None, transform=None)
Source code in wandas/utils/frame_dataset.py
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
def __init__(
    self,
    folder_path: str,
    sampling_rate: int | None = None,
    signal_length: int | None = None,
    file_extensions: list[str] | None = None,
    lazy_loading: bool = True,
    recursive: bool = False,
    source_dataset: Optional["FrameDataset[Any]"] = None,
    transform: Callable[[Any], SpectrogramFrame | None] | None = None,
):
    super().__init__(
        folder_path=folder_path,
        sampling_rate=sampling_rate,
        signal_length=signal_length,
        file_extensions=file_extensions,
        lazy_loading=lazy_loading,
        recursive=recursive,
        source_dataset=source_dataset,
        transform=transform,
    )
plot(index, **kwargs)

Plot the spectrogram at the specified index.

Source code in wandas/utils/frame_dataset.py
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
def plot(self, index: int, **kwargs: Any) -> None:
    """Plot the spectrogram at the specified index."""
    try:
        frame = self._ensure_loaded(index)

        if frame is None:
            logger.warning(
                f"Cannot plot index {index} as it failed to load/transform."
            )
            return

        plot_method = getattr(frame, "plot", None)
        if callable(plot_method):
            plot_method(**kwargs)
        else:
            logger.warning(
                f"Frame (index {index}, type {type(frame).__name__}) does not "
                f"have a plot method implemented."
            )
    except Exception as e:
        logger.error(f"An error occurred while plotting index {index}: {e}")

generate_sample

Classes
Functions
generate_sin(freqs=1000, sampling_rate=16000, duration=1.0, label=None)

Generate sample sine wave signals.

Parameters

freqs : float or list of float, default=1000 Frequency of the sine wave(s) in Hz. If multiple frequencies are specified, multiple channels will be created. sampling_rate : int, default=16000 Sampling rate in Hz. duration : float, default=1.0 Duration of the signal in seconds. label : str, optional Label for the entire signal.

Returns

ChannelFrame ChannelFrame object containing the sine wave(s).

Source code in wandas/utils/generate_sample.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def generate_sin(
    freqs: float | list[float] = 1000,
    sampling_rate: int = 16000,
    duration: float = 1.0,
    label: str | None = None,
) -> "ChannelFrame":
    """
    Generate sample sine wave signals.

    Parameters
    ----------
    freqs : float or list of float, default=1000
        Frequency of the sine wave(s) in Hz.
        If multiple frequencies are specified, multiple channels will be created.
    sampling_rate : int, default=16000
        Sampling rate in Hz.
    duration : float, default=1.0
        Duration of the signal in seconds.
    label : str, optional
        Label for the entire signal.

    Returns
    -------
    ChannelFrame
        ChannelFrame object containing the sine wave(s).
    """
    # 直接、generate_sin_lazy関数を呼び出す
    return generate_sin_lazy(
        freqs=freqs, sampling_rate=sampling_rate, duration=duration, label=label
    )
generate_sin_lazy(freqs=1000, sampling_rate=16000, duration=1.0, label=None)

Generate sample sine wave signals using lazy computation.

Parameters

freqs : float or list of float, default=1000 Frequency of the sine wave(s) in Hz. If multiple frequencies are specified, multiple channels will be created. sampling_rate : int, default=16000 Sampling rate in Hz. duration : float, default=1.0 Duration of the signal in seconds. label : str, optional Label for the entire signal.

Returns

ChannelFrame Lazy ChannelFrame object containing the sine wave(s).

Source code in wandas/utils/generate_sample.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def generate_sin_lazy(
    freqs: float | list[float] = 1000,
    sampling_rate: int = 16000,
    duration: float = 1.0,
    label: str | None = None,
) -> "ChannelFrame":
    """
    Generate sample sine wave signals using lazy computation.

    Parameters
    ----------
    freqs : float or list of float, default=1000
        Frequency of the sine wave(s) in Hz.
        If multiple frequencies are specified, multiple channels will be created.
    sampling_rate : int, default=16000
        Sampling rate in Hz.
    duration : float, default=1.0
        Duration of the signal in seconds.
    label : str, optional
        Label for the entire signal.

    Returns
    -------
    ChannelFrame
        Lazy ChannelFrame object containing the sine wave(s).
    """
    from wandas.frames.channel import ChannelFrame

    label = label or "Generated Sin"
    t = np.linspace(0, duration, int(sampling_rate * duration), endpoint=False)

    _freqs: list[float]
    if isinstance(freqs, float):
        _freqs = [freqs]
    elif isinstance(freqs, list):
        _freqs = freqs
    else:
        raise ValueError("freqs must be a float or a list of floats.")

    channels = []
    labels = []
    for idx, freq in enumerate(_freqs):
        data = np.sin(2 * np.pi * freq * t)
        labels.append(f"Channel {idx + 1}")
        channels.append(data)
    return ChannelFrame.from_numpy(
        data=np.array(channels),
        label=label,
        sampling_rate=sampling_rate,
        ch_labels=labels,
    )

introspection

Utilities for runtime signature introspection.

Attributes
__all__ = ['accepted_kwargs', 'filter_kwargs'] module-attribute
Functions
accepted_kwargs(func)

Get the set of explicit keyword arguments accepted by a function and whether it accepts **kwargs.

Parameters:

Name Type Description Default
func Callable[..., Any]

The function to inspect.

required

Returns:

Type Description
set[str]

A tuple containing:

bool
  • set[str]: Set of explicit keyword argument names accepted by func.
tuple[set[str], bool]
  • bool: Whether the function accepts variable keyword arguments (**kwargs).
Source code in wandas/utils/introspection.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def accepted_kwargs(func: Callable[..., Any]) -> tuple[set[str], bool]:
    """
    Get the set of explicit keyword arguments accepted by
    a function and whether it accepts **kwargs.

    Args:
        func: The function to inspect.

    Returns:
        A tuple containing:
        - set[str]: Set of explicit keyword argument names accepted by func.
        - bool: Whether the function accepts variable keyword arguments (**kwargs).
    """
    # モックオブジェクトの場合は空セットと無制限フラグを返す
    if hasattr(func, "__module__") and func.__module__ == "unittest.mock":
        return set(), True
    try:
        params = signature(func).parameters.values()

        # 明示的に定義されている引数を収集
        explicit_kwargs = {
            p.name
            for p in params
            if p.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
        }

        # **kwargsを受け付けるかどうかのフラグ
        has_var_kwargs = any(p.kind is Parameter.VAR_KEYWORD for p in params)

        return explicit_kwargs, has_var_kwargs
    except (ValueError, TypeError):
        # シグネチャを取得できない場合は空セットと無制限フラグを返す
        return set(), True
filter_kwargs(func, kwargs, *, strict_mode=False)

Filter keyword arguments to only those accepted by the function.

This function examines the signature of func and returns a dictionary containing only the key-value pairs from kwargs that are valid keyword arguments for func.

Parameters:

Name Type Description Default
func Callable[..., Any]

The function to filter keyword arguments for.

required
kwargs Mapping[str, Any]

The keyword arguments to filter.

required
strict_mode bool

If True, only explicitly defined parameters are passed even when the function accepts kwargs. If False (default), all parameters are passed to functions that accept kwargs, but a warning is issued for parameters not explicitly defined.

False

Returns:

Type Description
dict[str, Any]

A dictionary containing only the key-value pairs that are valid for func.

Source code in wandas/utils/introspection.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def filter_kwargs(
    func: Callable[..., Any],
    kwargs: Mapping[str, Any],
    *,
    strict_mode: bool = False,
) -> dict[str, Any]:
    """
    Filter keyword arguments to only those accepted by the function.

    This function examines the signature of `func` and returns a dictionary
    containing only the key-value pairs from `kwargs` that are valid keyword
    arguments for `func`.

    Args:
        func: The function to filter keyword arguments for.
        kwargs: The keyword arguments to filter.
        strict_mode: If True, only explicitly defined parameters are passed even when
            the function accepts **kwargs. If False (default), all parameters are
            passed to functions that accept **kwargs, but a warning is issued for
            parameters not explicitly defined.

    Returns:
        A dictionary containing only the key-value pairs that are valid for `func`.
    """
    explicit_params, accepts_var_kwargs = accepted_kwargs(func)

    # **kwargsを受け付けない場合、または strict_mode が True の場合は、
    # 明示的なパラメータのみをフィルタリング
    if not accepts_var_kwargs or strict_mode:
        filtered = {k: v for k, v in kwargs.items() if k in explicit_params}
        return filtered

    # **kwargsを受け付ける場合(strict_modeがFalseの場合)は全キーを許可
    # ただし、明示的に定義されていないキーには警告を出す
    unknown = set(kwargs) - explicit_params
    if unknown:
        warnings.warn(
            f"Implicit kwargs for {func.__name__}: {unknown}",
            UserWarning,
            stacklevel=2,
        )
    return dict(kwargs)

types

Attributes
Real = np.number[Any] module-attribute
Complex = np.complexfloating[Any, Any] module-attribute
NDArrayReal = npt.NDArray[Real] module-attribute
NDArrayComplex = npt.NDArray[Complex] module-attribute

util

Attributes
Functions
validate_sampling_rate(sampling_rate, param_name='sampling_rate')

Validate that sampling rate is positive.

Parameters

sampling_rate : float Sampling rate in Hz to validate. param_name : str, default="sampling_rate" Name of the parameter being validated (for error messages).

Raises

ValueError If sampling_rate is not positive (i.e., <= 0).

Examples

validate_sampling_rate(44100) # No error validate_sampling_rate(0) # Raises ValueError validate_sampling_rate(-100) # Raises ValueError

Source code in wandas/utils/util.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def validate_sampling_rate(
    sampling_rate: float, param_name: str = "sampling_rate"
) -> None:
    """
    Validate that sampling rate is positive.

    Parameters
    ----------
    sampling_rate : float
        Sampling rate in Hz to validate.
    param_name : str, default="sampling_rate"
        Name of the parameter being validated (for error messages).

    Raises
    ------
    ValueError
        If sampling_rate is not positive (i.e., <= 0).

    Examples
    --------
    >>> validate_sampling_rate(44100)  # No error
    >>> validate_sampling_rate(0)  # Raises ValueError
    >>> validate_sampling_rate(-100)  # Raises ValueError
    """
    if sampling_rate <= 0:
        raise ValueError(
            f"Invalid {param_name}\n"
            f"  Got: {sampling_rate} Hz\n"
            f"  Expected: Positive value > 0\n"
            f"Sampling rate represents samples per second and must be positive.\n"
            f"Common values: 8000, 16000, 22050, 44100, 48000 Hz"
        )
unit_to_ref(unit)

Convert unit to reference value.

Parameters

unit : str Unit string.

Returns

float Reference value for the unit. For 'Pa', returns 2e-5 (20 μPa). For other units, returns 1.0.

Source code in wandas/utils/util.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def unit_to_ref(unit: str) -> float:
    """
    Convert unit to reference value.

    Parameters
    ----------
    unit : str
        Unit string.

    Returns
    -------
    float
        Reference value for the unit. For 'Pa', returns 2e-5 (20 μPa).
        For other units, returns 1.0.
    """
    if unit == "Pa":
        return 2e-5

    else:
        return 1.0
calculate_rms(wave)

Calculate the root mean square of the wave.

Parameters

wave : NDArrayReal Input waveform data. Can be multi-channel (shape: [channels, samples]) or single channel (shape: [samples]).

Returns

Union[float, NDArray[np.float64]] RMS value(s). For multi-channel input, returns an array of RMS values, one per channel. For single-channel input, returns a single RMS value.

Source code in wandas/utils/util.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def calculate_rms(wave: "NDArrayReal") -> "NDArrayReal":
    """
    Calculate the root mean square of the wave.

    Parameters
    ----------
    wave : NDArrayReal
        Input waveform data. Can be multi-channel (shape: [channels, samples])
        or single channel (shape: [samples]).

    Returns
    -------
    Union[float, NDArray[np.float64]]
        RMS value(s). For multi-channel input, returns an array of RMS values,
        one per channel. For single-channel input, returns a single RMS value.
    """
    # Calculate RMS considering axis (over the last dimension)
    axis_to_use = -1 if wave.ndim > 1 else None
    rms_values: NDArrayReal = np.sqrt(
        np.mean(np.square(wave), axis=axis_to_use, keepdims=True)
    )
    return rms_values
calculate_desired_noise_rms(clean_rms, snr)

Calculate the desired noise RMS based on clean signal RMS and target SNR.

Parameters

clean_rms : "NDArrayReal" RMS value(s) of the clean signal. Can be a single value or an array for multi-channel. snr : float Target Signal-to-Noise Ratio in dB.

Returns

"NDArrayReal" Desired noise RMS value(s) to achieve the target SNR.

Source code in wandas/utils/util.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def calculate_desired_noise_rms(clean_rms: "NDArrayReal", snr: float) -> "NDArrayReal":
    """
    Calculate the desired noise RMS based on clean signal RMS and target SNR.

    Parameters
    ----------
    clean_rms : "NDArrayReal"
        RMS value(s) of the clean signal.
        Can be a single value or an array for multi-channel.
    snr : float
        Target Signal-to-Noise Ratio in dB.

    Returns
    -------
    "NDArrayReal"
        Desired noise RMS value(s) to achieve the target SNR.
    """
    a = snr / 20
    noise_rms = clean_rms / (10**a)
    return noise_rms
amplitude_to_db(amplitude, ref)

Convert amplitude to decibel.

Parameters

amplitude : NDArrayReal Input amplitude data. ref : float Reference value for conversion.

Returns

NDArrayReal Amplitude data converted to decibels.

Source code in wandas/utils/util.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def amplitude_to_db(amplitude: "NDArrayReal", ref: float) -> "NDArrayReal":
    """
    Convert amplitude to decibel.

    Parameters
    ----------
    amplitude : NDArrayReal
        Input amplitude data.
    ref : float
        Reference value for conversion.

    Returns
    -------
    NDArrayReal
        Amplitude data converted to decibels.
    """
    db: NDArrayReal = librosa.amplitude_to_db(
        np.abs(amplitude), ref=ref, amin=1e-15, top_db=None
    )
    return db
level_trigger(data, level, offset=0, hold=1)

Find points where the signal crosses the specified level from below.

Parameters

data : NDArrayReal Input signal data. level : float Threshold level for triggering. offset : int, default=0 Offset to add to trigger points. hold : int, default=1 Minimum number of samples between successive trigger points.

Returns

list of int List of sample indices where the signal crosses the level.

Source code in wandas/utils/util.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def level_trigger(
    data: "NDArrayReal", level: float, offset: int = 0, hold: int = 1
) -> list[int]:
    """
    Find points where the signal crosses the specified level from below.

    Parameters
    ----------
    data : NDArrayReal
        Input signal data.
    level : float
        Threshold level for triggering.
    offset : int, default=0
        Offset to add to trigger points.
    hold : int, default=1
        Minimum number of samples between successive trigger points.

    Returns
    -------
    list of int
        List of sample indices where the signal crosses the level.
    """
    trig_point: list[int] = []

    sig_len = len(data)
    diff = np.diff(np.sign(data - level))
    level_point = np.where(diff > 0)[0]
    level_point = level_point[(level_point + hold) < sig_len]

    if len(level_point) == 0:
        return list()

    last_point = level_point[0]
    trig_point.append(last_point + offset)
    for i in level_point:
        if (last_point + hold) < i:
            trig_point.append(i + offset)
            last_point = i

    return trig_point
cut_sig(data, point_list, cut_len, taper_rate=0, dc_cut=False)

Cut segments from signal at specified points.

Parameters

data : NDArrayReal Input signal data. point_list : list of int List of starting points for cutting. cut_len : int Length of each segment to cut. taper_rate : float, default=0 Taper rate for Tukey window applied to segments. A value of 0 means no tapering, 1 means full tapering. dc_cut : bool, default=False Whether to remove DC component (mean) from segments.

Returns

NDArrayReal Array containing cut segments with shape (n_segments, cut_len).

Source code in wandas/utils/util.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def cut_sig(
    data: "NDArrayReal",
    point_list: list[int],
    cut_len: int,
    taper_rate: float = 0,
    dc_cut: bool = False,
) -> "NDArrayReal":
    """
    Cut segments from signal at specified points.

    Parameters
    ----------
    data : NDArrayReal
        Input signal data.
    point_list : list of int
        List of starting points for cutting.
    cut_len : int
        Length of each segment to cut.
    taper_rate : float, default=0
        Taper rate for Tukey window applied to segments.
        A value of 0 means no tapering, 1 means full tapering.
    dc_cut : bool, default=False
        Whether to remove DC component (mean) from segments.

    Returns
    -------
    NDArrayReal
        Array containing cut segments with shape (n_segments, cut_len).
    """
    length = len(data)
    point_list_ = [p for p in point_list if p >= 0 and p + cut_len <= length]
    trial: NDArrayReal = np.zeros((len(point_list_), cut_len))

    for i, v in enumerate(point_list_):
        trial[i] = data[v : v + cut_len]
        if dc_cut:
            trial[i] = trial[i] - trial[i].mean()

    win: NDArrayReal = tukey(cut_len, taper_rate).astype(trial.dtype)[np.newaxis, :]
    trial = trial * win
    return trial

可視化モジュール

可視化モジュールはデータの視覚化機能を提供します。

wandas.visualization

Modules

plotting

Attributes
logger = logging.getLogger(__name__) module-attribute
TFrame = TypeVar('TFrame', bound='BaseFrame[Any]') module-attribute
Classes
PlotStrategy

Bases: ABC, Generic[TFrame]

Base class for plotting strategies

Source code in wandas/visualization/plotting.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class PlotStrategy(abc.ABC, Generic[TFrame]):
    """Base class for plotting strategies"""

    name: ClassVar[str]

    @abc.abstractmethod
    def channel_plot(self, x: Any, y: Any, ax: "Axes") -> None:
        """Implementation of channel plotting"""
        pass

    @abc.abstractmethod
    def plot(
        self,
        bf: TFrame,
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Implementation of plotting"""
        pass
Attributes
name class-attribute
Functions
channel_plot(x, y, ax) abstractmethod

Implementation of channel plotting

Source code in wandas/visualization/plotting.py
43
44
45
46
@abc.abstractmethod
def channel_plot(self, x: Any, y: Any, ax: "Axes") -> None:
    """Implementation of channel plotting"""
    pass
plot(bf, ax=None, title=None, overlay=False, **kwargs) abstractmethod

Implementation of plotting

Source code in wandas/visualization/plotting.py
48
49
50
51
52
53
54
55
56
57
58
@abc.abstractmethod
def plot(
    self,
    bf: TFrame,
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Implementation of plotting"""
    pass
WaveformPlotStrategy

Bases: PlotStrategy['ChannelFrame']

Strategy for waveform plotting

Source code in wandas/visualization/plotting.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
class WaveformPlotStrategy(PlotStrategy["ChannelFrame"]):
    """Strategy for waveform plotting"""

    name = "waveform"

    def channel_plot(
        self,
        x: Any,
        y: Any,
        ax: "Axes",
        **kwargs: Any,
    ) -> None:
        """Implementation of channel plotting"""
        ax.plot(x, y, **kwargs)
        ax.set_ylabel("Amplitude")
        ax.grid(True)
        if "label" in kwargs:
            ax.legend()

    def plot(
        self,
        bf: "ChannelFrame",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Waveform plotting"""
        kwargs = kwargs or {}
        ylabel = kwargs.pop("ylabel", "Amplitude")
        xlabel = kwargs.pop("xlabel", "Time [s]")
        alpha = kwargs.pop("alpha", 1)
        plot_kwargs = filter_kwargs(
            Line2D,
            kwargs,
            strict_mode=True,
        )
        ax_set = filter_kwargs(
            Axes.set,
            kwargs,
            strict_mode=True,
        )
        # If an Axes is provided, prefer drawing into it (treat as overlay)
        if ax is not None:
            overlay = True
        data = bf.data
        data = _reshape_to_2d(data)
        if overlay:
            if ax is None:
                fig, ax = plt.subplots(figsize=(10, 4))

            self.channel_plot(
                bf.time, data.T, ax, label=bf.labels, alpha=alpha, **plot_kwargs
            )
            ax.set(
                ylabel=ylabel,
                title=title or bf.label or "Channel Data",
                xlabel=xlabel,
                **ax_set,
            )
            if ax is None:
                fig.suptitle(title or bf.label or None)
                plt.tight_layout()
                plt.show()
            return ax
        else:
            num_channels = bf.n_channels
            fig, axs = plt.subplots(
                num_channels, 1, figsize=(10, 4 * num_channels), sharex=True
            )
            # Convert axs to list if it is a single Axes object
            if not isinstance(axs, list | np.ndarray):
                axs = [axs]

            axes_list = list(axs)
            for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
                self.channel_plot(
                    bf.time, channel_data, ax_i, alpha=alpha, **plot_kwargs
                )
                ax_i.set(
                    ylabel=ylabel + f" [{ch_meta.unit}]",
                    title=ch_meta.label,
                    **ax_set,
                )

            axes_list[-1].set(
                xlabel="Time [s]",
            )
            fig.suptitle(title or bf.label or "Channel Data")

            if ax is None:
                plt.tight_layout()
                plt.show()

            return _return_axes_iterator(fig.axes)
Attributes
name = 'waveform' class-attribute instance-attribute
Functions
channel_plot(x, y, ax, **kwargs)

Implementation of channel plotting

Source code in wandas/visualization/plotting.py
120
121
122
123
124
125
126
127
128
129
130
131
132
def channel_plot(
    self,
    x: Any,
    y: Any,
    ax: "Axes",
    **kwargs: Any,
) -> None:
    """Implementation of channel plotting"""
    ax.plot(x, y, **kwargs)
    ax.set_ylabel("Amplitude")
    ax.grid(True)
    if "label" in kwargs:
        ax.legend()
plot(bf, ax=None, title=None, overlay=False, **kwargs)

Waveform plotting

Source code in wandas/visualization/plotting.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def plot(
    self,
    bf: "ChannelFrame",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Waveform plotting"""
    kwargs = kwargs or {}
    ylabel = kwargs.pop("ylabel", "Amplitude")
    xlabel = kwargs.pop("xlabel", "Time [s]")
    alpha = kwargs.pop("alpha", 1)
    plot_kwargs = filter_kwargs(
        Line2D,
        kwargs,
        strict_mode=True,
    )
    ax_set = filter_kwargs(
        Axes.set,
        kwargs,
        strict_mode=True,
    )
    # If an Axes is provided, prefer drawing into it (treat as overlay)
    if ax is not None:
        overlay = True
    data = bf.data
    data = _reshape_to_2d(data)
    if overlay:
        if ax is None:
            fig, ax = plt.subplots(figsize=(10, 4))

        self.channel_plot(
            bf.time, data.T, ax, label=bf.labels, alpha=alpha, **plot_kwargs
        )
        ax.set(
            ylabel=ylabel,
            title=title or bf.label or "Channel Data",
            xlabel=xlabel,
            **ax_set,
        )
        if ax is None:
            fig.suptitle(title or bf.label or None)
            plt.tight_layout()
            plt.show()
        return ax
    else:
        num_channels = bf.n_channels
        fig, axs = plt.subplots(
            num_channels, 1, figsize=(10, 4 * num_channels), sharex=True
        )
        # Convert axs to list if it is a single Axes object
        if not isinstance(axs, list | np.ndarray):
            axs = [axs]

        axes_list = list(axs)
        for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
            self.channel_plot(
                bf.time, channel_data, ax_i, alpha=alpha, **plot_kwargs
            )
            ax_i.set(
                ylabel=ylabel + f" [{ch_meta.unit}]",
                title=ch_meta.label,
                **ax_set,
            )

        axes_list[-1].set(
            xlabel="Time [s]",
        )
        fig.suptitle(title or bf.label or "Channel Data")

        if ax is None:
            plt.tight_layout()
            plt.show()

        return _return_axes_iterator(fig.axes)
FrequencyPlotStrategy

Bases: PlotStrategy['SpectralFrame']

Strategy for frequency domain plotting

Source code in wandas/visualization/plotting.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
class FrequencyPlotStrategy(PlotStrategy["SpectralFrame"]):
    """Strategy for frequency domain plotting"""

    name = "frequency"

    def channel_plot(
        self,
        x: Any,
        y: Any,
        ax: "Axes",
        **kwargs: Any,
    ) -> None:
        """Implementation of channel plotting"""
        ax.plot(x, y, **kwargs)
        ax.grid(True)
        if "label" in kwargs:
            ax.legend()

    def plot(
        self,
        bf: "SpectralFrame",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Frequency domain plotting"""
        kwargs = kwargs or {}
        is_aw = kwargs.pop("Aw", False)
        if (
            len(bf.operation_history) > 0
            and bf.operation_history[-1]["operation"] == "coherence"
        ):
            unit = ""
            data = bf.magnitude
            ylabel = kwargs.pop("ylabel", "coherence")
        else:
            if is_aw:
                unit = "dBA"
                data = bf.dBA
            else:
                unit = "dB"
                data = bf.dB
            ylabel = kwargs.pop("ylabel", f"Spectrum level [{unit}]")
        data = _reshape_to_2d(data)
        xlabel = kwargs.pop("xlabel", "Frequency [Hz]")
        alpha = kwargs.pop("alpha", 1)
        plot_kwargs = filter_kwargs(Line2D, kwargs, strict_mode=True)
        ax_set = filter_kwargs(Axes.set, kwargs, strict_mode=True)
        # If an Axes is provided, prefer drawing into it (treat as overlay)
        if ax is not None:
            overlay = True
        if overlay:
            if ax is None:
                _, ax = plt.subplots(figsize=(10, 4))
            self.channel_plot(
                bf.freqs,
                data.T,
                ax,
                label=bf.labels,
                alpha=alpha,
                **plot_kwargs,
            )
            ax.set(
                ylabel=ylabel,
                xlabel=xlabel,
                title=title or bf.label or "Channel Data",
                **ax_set,
            )
            if ax is None:
                plt.tight_layout()
                plt.show()
            return ax
        else:
            num_channels = bf.n_channels
            fig, axs = plt.subplots(
                num_channels, 1, figsize=(10, 4 * num_channels), sharex=True
            )
            # Convert axs to list if it is a single Axes object
            if not isinstance(axs, list | np.ndarray):
                axs = [axs]

            axes_list = list(axs)
            for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
                self.channel_plot(
                    bf.freqs,
                    channel_data,
                    ax_i,
                    label=ch_meta.label,
                    alpha=alpha,
                    **plot_kwargs,
                )
                ax_i.set(
                    ylabel=ylabel,
                    title=ch_meta.label,
                    xlabel=xlabel,
                    **ax_set,
                )
            axes_list[-1].set(ylabel=ylabel, xlabel=xlabel)
            fig.suptitle(title or bf.label or "Channel Data")
            if ax is None:
                plt.tight_layout()
                plt.show()
            return _return_axes_iterator(fig.axes)
Attributes
name = 'frequency' class-attribute instance-attribute
Functions
channel_plot(x, y, ax, **kwargs)

Implementation of channel plotting

Source code in wandas/visualization/plotting.py
217
218
219
220
221
222
223
224
225
226
227
228
def channel_plot(
    self,
    x: Any,
    y: Any,
    ax: "Axes",
    **kwargs: Any,
) -> None:
    """Implementation of channel plotting"""
    ax.plot(x, y, **kwargs)
    ax.grid(True)
    if "label" in kwargs:
        ax.legend()
plot(bf, ax=None, title=None, overlay=False, **kwargs)

Frequency domain plotting

Source code in wandas/visualization/plotting.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def plot(
    self,
    bf: "SpectralFrame",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Frequency domain plotting"""
    kwargs = kwargs or {}
    is_aw = kwargs.pop("Aw", False)
    if (
        len(bf.operation_history) > 0
        and bf.operation_history[-1]["operation"] == "coherence"
    ):
        unit = ""
        data = bf.magnitude
        ylabel = kwargs.pop("ylabel", "coherence")
    else:
        if is_aw:
            unit = "dBA"
            data = bf.dBA
        else:
            unit = "dB"
            data = bf.dB
        ylabel = kwargs.pop("ylabel", f"Spectrum level [{unit}]")
    data = _reshape_to_2d(data)
    xlabel = kwargs.pop("xlabel", "Frequency [Hz]")
    alpha = kwargs.pop("alpha", 1)
    plot_kwargs = filter_kwargs(Line2D, kwargs, strict_mode=True)
    ax_set = filter_kwargs(Axes.set, kwargs, strict_mode=True)
    # If an Axes is provided, prefer drawing into it (treat as overlay)
    if ax is not None:
        overlay = True
    if overlay:
        if ax is None:
            _, ax = plt.subplots(figsize=(10, 4))
        self.channel_plot(
            bf.freqs,
            data.T,
            ax,
            label=bf.labels,
            alpha=alpha,
            **plot_kwargs,
        )
        ax.set(
            ylabel=ylabel,
            xlabel=xlabel,
            title=title or bf.label or "Channel Data",
            **ax_set,
        )
        if ax is None:
            plt.tight_layout()
            plt.show()
        return ax
    else:
        num_channels = bf.n_channels
        fig, axs = plt.subplots(
            num_channels, 1, figsize=(10, 4 * num_channels), sharex=True
        )
        # Convert axs to list if it is a single Axes object
        if not isinstance(axs, list | np.ndarray):
            axs = [axs]

        axes_list = list(axs)
        for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
            self.channel_plot(
                bf.freqs,
                channel_data,
                ax_i,
                label=ch_meta.label,
                alpha=alpha,
                **plot_kwargs,
            )
            ax_i.set(
                ylabel=ylabel,
                title=ch_meta.label,
                xlabel=xlabel,
                **ax_set,
            )
        axes_list[-1].set(ylabel=ylabel, xlabel=xlabel)
        fig.suptitle(title or bf.label or "Channel Data")
        if ax is None:
            plt.tight_layout()
            plt.show()
        return _return_axes_iterator(fig.axes)
NOctPlotStrategy

Bases: PlotStrategy['NOctFrame']

Strategy for N-octave band analysis plotting

Source code in wandas/visualization/plotting.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
class NOctPlotStrategy(PlotStrategy["NOctFrame"]):
    """Strategy for N-octave band analysis plotting"""

    name = "noct"

    def channel_plot(
        self,
        x: Any,
        y: Any,
        ax: "Axes",
        **kwargs: Any,
    ) -> None:
        """Implementation of channel plotting"""
        ax.step(x, y, **kwargs)
        ax.grid(True)
        if "label" in kwargs:
            ax.legend()

    def plot(
        self,
        bf: "NOctFrame",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """N-octave band analysis plotting"""
        kwargs = kwargs or {}
        is_aw = kwargs.pop("Aw", False)

        if is_aw:
            unit = "dBrA"
            data = bf.dBA
        else:
            unit = "dBr"
            data = bf.dB
        data = _reshape_to_2d(data)
        ylabel = kwargs.pop("ylabel", f"Spectrum level [{unit}]")
        xlabel = kwargs.pop("xlabel", "Center frequency [Hz]")
        alpha = kwargs.pop("alpha", 1)
        plot_kwargs = filter_kwargs(Line2D, kwargs, strict_mode=True)
        ax_set = filter_kwargs(Axes.set, kwargs, strict_mode=True)
        # If an Axes is provided, prefer drawing into it (treat as overlay)
        if ax is not None:
            overlay = True
        if overlay:
            if ax is None:
                _, ax = plt.subplots(figsize=(10, 4))
            self.channel_plot(
                bf.freqs,
                data.T,
                ax,
                label=bf.labels,
                alpha=alpha,
                **plot_kwargs,
            )
            default_title = f"1/{str(bf.n)}-Octave Spectrum"
            actual_title = title if title else (bf.label or default_title)
            ax.set(
                ylabel=ylabel,
                xlabel=xlabel,
                title=actual_title,
                **ax_set,
            )
            if ax is None:
                plt.tight_layout()
                plt.show()
            return ax
        else:
            num_channels = bf.n_channels
            fig, axs = plt.subplots(
                num_channels, 1, figsize=(10, 4 * num_channels), sharex=True
            )
            # Convert axs to list if it is a single Axes object
            if not isinstance(axs, list | np.ndarray):
                axs = [axs]

            axes_list = list(axs)
            for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
                self.channel_plot(
                    bf.freqs,
                    channel_data,
                    ax_i,
                    label=ch_meta.label,
                    alpha=alpha,
                    **plot_kwargs,
                )
                ax_i.set(
                    ylabel=ylabel,
                    title=ch_meta.label,
                    xlabel=xlabel,
                    **ax_set,
                )
            axes_list[-1].set(ylabel=ylabel, xlabel=xlabel)
            fig.suptitle(title or bf.label or f"1/{str(bf.n)}-Octave Spectrum")
            if ax is None:
                plt.tight_layout()
                plt.show()
            return _return_axes_iterator(fig.axes)
Attributes
name = 'noct' class-attribute instance-attribute
Functions
channel_plot(x, y, ax, **kwargs)

Implementation of channel plotting

Source code in wandas/visualization/plotting.py
323
324
325
326
327
328
329
330
331
332
333
334
def channel_plot(
    self,
    x: Any,
    y: Any,
    ax: "Axes",
    **kwargs: Any,
) -> None:
    """Implementation of channel plotting"""
    ax.step(x, y, **kwargs)
    ax.grid(True)
    if "label" in kwargs:
        ax.legend()
plot(bf, ax=None, title=None, overlay=False, **kwargs)

N-octave band analysis plotting

Source code in wandas/visualization/plotting.py
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
def plot(
    self,
    bf: "NOctFrame",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """N-octave band analysis plotting"""
    kwargs = kwargs or {}
    is_aw = kwargs.pop("Aw", False)

    if is_aw:
        unit = "dBrA"
        data = bf.dBA
    else:
        unit = "dBr"
        data = bf.dB
    data = _reshape_to_2d(data)
    ylabel = kwargs.pop("ylabel", f"Spectrum level [{unit}]")
    xlabel = kwargs.pop("xlabel", "Center frequency [Hz]")
    alpha = kwargs.pop("alpha", 1)
    plot_kwargs = filter_kwargs(Line2D, kwargs, strict_mode=True)
    ax_set = filter_kwargs(Axes.set, kwargs, strict_mode=True)
    # If an Axes is provided, prefer drawing into it (treat as overlay)
    if ax is not None:
        overlay = True
    if overlay:
        if ax is None:
            _, ax = plt.subplots(figsize=(10, 4))
        self.channel_plot(
            bf.freqs,
            data.T,
            ax,
            label=bf.labels,
            alpha=alpha,
            **plot_kwargs,
        )
        default_title = f"1/{str(bf.n)}-Octave Spectrum"
        actual_title = title if title else (bf.label or default_title)
        ax.set(
            ylabel=ylabel,
            xlabel=xlabel,
            title=actual_title,
            **ax_set,
        )
        if ax is None:
            plt.tight_layout()
            plt.show()
        return ax
    else:
        num_channels = bf.n_channels
        fig, axs = plt.subplots(
            num_channels, 1, figsize=(10, 4 * num_channels), sharex=True
        )
        # Convert axs to list if it is a single Axes object
        if not isinstance(axs, list | np.ndarray):
            axs = [axs]

        axes_list = list(axs)
        for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
            self.channel_plot(
                bf.freqs,
                channel_data,
                ax_i,
                label=ch_meta.label,
                alpha=alpha,
                **plot_kwargs,
            )
            ax_i.set(
                ylabel=ylabel,
                title=ch_meta.label,
                xlabel=xlabel,
                **ax_set,
            )
        axes_list[-1].set(ylabel=ylabel, xlabel=xlabel)
        fig.suptitle(title or bf.label or f"1/{str(bf.n)}-Octave Spectrum")
        if ax is None:
            plt.tight_layout()
            plt.show()
        return _return_axes_iterator(fig.axes)
SpectrogramPlotStrategy

Bases: PlotStrategy['SpectrogramFrame']

Strategy for spectrogram plotting

Source code in wandas/visualization/plotting.py
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
class SpectrogramPlotStrategy(PlotStrategy["SpectrogramFrame"]):
    """Strategy for spectrogram plotting"""

    name = "spectrogram"

    def channel_plot(
        self,
        x: Any,
        y: Any,
        ax: "Axes",
        **kwargs: Any,
    ) -> None:
        """Implementation of channel plotting"""
        pass

    def plot(
        self,
        bf: "SpectrogramFrame",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Spectrogram plotting"""
        # Explicit overlay mode is not supported for spectrograms
        if overlay:
            raise ValueError("Overlay is not supported for SpectrogramPlotStrategy.")

        # If an Axes is provided, allow drawing into it only for single-channel frames
        if ax is not None and bf.n_channels > 1:
            raise ValueError("ax must be None when n_channels > 1.")

        kwargs = kwargs or {}

        is_aw = kwargs.pop("Aw", False)
        if is_aw:
            unit = "dBA"
            data = bf.dBA
        else:
            unit = "dB"
            data = bf.dB
        data = _reshape_spectrogram_data(data)
        specshow_kwargs = filter_kwargs(display.specshow, kwargs, strict_mode=True)
        ax_set_kwargs = filter_kwargs(Axes.set, kwargs, strict_mode=True)

        cmap = kwargs.pop("cmap", "jet")
        vmin = kwargs.pop("vmin", None)
        vmax = kwargs.pop("vmax", None)

        if ax is not None:
            img = display.specshow(
                data=data[0],
                sr=bf.sampling_rate,
                hop_length=bf.hop_length,
                n_fft=bf.n_fft,
                win_length=bf.win_length,
                x_axis="time",
                y_axis="linear",
                cmap=cmap,
                ax=ax,
                vmin=vmin,
                vmax=vmax,
                **specshow_kwargs,
            )
            ax.set(
                title=title or bf.label or "Spectrogram",
                ylabel="Frequency [Hz]",
                xlabel="Time [s]",
                **ax_set_kwargs,
            )

            fig = ax.figure
            if fig is not None:
                try:
                    cbar = fig.colorbar(img, ax=ax)
                    cbar.set_label(f"Spectrum level [{unit}]")
                except (ValueError, AttributeError) as e:
                    # Handle case where img doesn't have proper colorbar properties
                    logger.warning(
                        f"Failed to create colorbar for spectrogram: "
                        f"{type(e).__name__}: {e}"
                    )
            return ax

        else:
            # Create a new figure if ax is None
            num_channels = bf.n_channels
            fig, axs = plt.subplots(
                num_channels, 1, figsize=(10, 5 * num_channels), sharex=True
            )
            if not isinstance(fig, Figure):
                raise ValueError("fig must be a matplotlib Figure object.")
            # Convert axs to array if it is a single Axes object
            if not isinstance(axs, np.ndarray):
                axs = np.array([axs])

            for ax_i, channel_data, ch_meta in zip(axs.flatten(), data, bf.channels):
                img = display.specshow(
                    data=channel_data,
                    sr=bf.sampling_rate,
                    hop_length=bf.hop_length,
                    n_fft=bf.n_fft,
                    win_length=bf.win_length,
                    x_axis="time",
                    y_axis="linear",
                    ax=ax_i,
                    cmap=cmap,
                    vmin=vmin,
                    vmax=vmax,
                    **specshow_kwargs,
                )
                ax_i.set(
                    title=ch_meta.label,
                    ylabel="Frequency [Hz]",
                    xlabel="Time [s]",
                    **ax_set_kwargs,
                )
                try:
                    cbar = ax_i.figure.colorbar(img, ax=ax_i)
                    cbar.set_label(f"Spectrum level [{unit}]")
                except (ValueError, AttributeError) as e:
                    # Handle case where img doesn't have proper colorbar properties
                    logger.warning(
                        f"Failed to create colorbar for spectrogram: "
                        f"{type(e).__name__}: {e}"
                    )
                fig.suptitle(title or "Spectrogram Data")
            plt.tight_layout()
            plt.show()

            return _return_axes_iterator(fig.axes)
Attributes
name = 'spectrogram' class-attribute instance-attribute
Functions
channel_plot(x, y, ax, **kwargs)

Implementation of channel plotting

Source code in wandas/visualization/plotting.py
424
425
426
427
428
429
430
431
432
def channel_plot(
    self,
    x: Any,
    y: Any,
    ax: "Axes",
    **kwargs: Any,
) -> None:
    """Implementation of channel plotting"""
    pass
plot(bf, ax=None, title=None, overlay=False, **kwargs)

Spectrogram plotting

Source code in wandas/visualization/plotting.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
def plot(
    self,
    bf: "SpectrogramFrame",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Spectrogram plotting"""
    # Explicit overlay mode is not supported for spectrograms
    if overlay:
        raise ValueError("Overlay is not supported for SpectrogramPlotStrategy.")

    # If an Axes is provided, allow drawing into it only for single-channel frames
    if ax is not None and bf.n_channels > 1:
        raise ValueError("ax must be None when n_channels > 1.")

    kwargs = kwargs or {}

    is_aw = kwargs.pop("Aw", False)
    if is_aw:
        unit = "dBA"
        data = bf.dBA
    else:
        unit = "dB"
        data = bf.dB
    data = _reshape_spectrogram_data(data)
    specshow_kwargs = filter_kwargs(display.specshow, kwargs, strict_mode=True)
    ax_set_kwargs = filter_kwargs(Axes.set, kwargs, strict_mode=True)

    cmap = kwargs.pop("cmap", "jet")
    vmin = kwargs.pop("vmin", None)
    vmax = kwargs.pop("vmax", None)

    if ax is not None:
        img = display.specshow(
            data=data[0],
            sr=bf.sampling_rate,
            hop_length=bf.hop_length,
            n_fft=bf.n_fft,
            win_length=bf.win_length,
            x_axis="time",
            y_axis="linear",
            cmap=cmap,
            ax=ax,
            vmin=vmin,
            vmax=vmax,
            **specshow_kwargs,
        )
        ax.set(
            title=title or bf.label or "Spectrogram",
            ylabel="Frequency [Hz]",
            xlabel="Time [s]",
            **ax_set_kwargs,
        )

        fig = ax.figure
        if fig is not None:
            try:
                cbar = fig.colorbar(img, ax=ax)
                cbar.set_label(f"Spectrum level [{unit}]")
            except (ValueError, AttributeError) as e:
                # Handle case where img doesn't have proper colorbar properties
                logger.warning(
                    f"Failed to create colorbar for spectrogram: "
                    f"{type(e).__name__}: {e}"
                )
        return ax

    else:
        # Create a new figure if ax is None
        num_channels = bf.n_channels
        fig, axs = plt.subplots(
            num_channels, 1, figsize=(10, 5 * num_channels), sharex=True
        )
        if not isinstance(fig, Figure):
            raise ValueError("fig must be a matplotlib Figure object.")
        # Convert axs to array if it is a single Axes object
        if not isinstance(axs, np.ndarray):
            axs = np.array([axs])

        for ax_i, channel_data, ch_meta in zip(axs.flatten(), data, bf.channels):
            img = display.specshow(
                data=channel_data,
                sr=bf.sampling_rate,
                hop_length=bf.hop_length,
                n_fft=bf.n_fft,
                win_length=bf.win_length,
                x_axis="time",
                y_axis="linear",
                ax=ax_i,
                cmap=cmap,
                vmin=vmin,
                vmax=vmax,
                **specshow_kwargs,
            )
            ax_i.set(
                title=ch_meta.label,
                ylabel="Frequency [Hz]",
                xlabel="Time [s]",
                **ax_set_kwargs,
            )
            try:
                cbar = ax_i.figure.colorbar(img, ax=ax_i)
                cbar.set_label(f"Spectrum level [{unit}]")
            except (ValueError, AttributeError) as e:
                # Handle case where img doesn't have proper colorbar properties
                logger.warning(
                    f"Failed to create colorbar for spectrogram: "
                    f"{type(e).__name__}: {e}"
                )
            fig.suptitle(title or "Spectrogram Data")
        plt.tight_layout()
        plt.show()

        return _return_axes_iterator(fig.axes)
DescribePlotStrategy

Bases: PlotStrategy['ChannelFrame']

Strategy for visualizing ChannelFrame data with describe plot

Source code in wandas/visualization/plotting.py
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
class DescribePlotStrategy(PlotStrategy["ChannelFrame"]):
    """Strategy for visualizing ChannelFrame data with describe plot"""

    name = "describe"

    def channel_plot(self, x: Any, y: Any, ax: "Axes", **kwargs: Any) -> None:
        """Implementation of channel plotting"""
        pass  # This method is not used for describe plot

    def plot(
        self,
        bf: "ChannelFrame",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        """Implementation of describe method for visualizing ChannelFrame data"""

        fmin = kwargs.pop("fmin", 0)
        fmax = kwargs.pop("fmax", None)
        cmap = kwargs.pop("cmap", "jet")
        vmin = kwargs.pop("vmin", None)
        vmax = kwargs.pop("vmax", None)
        xlim = kwargs.pop("xlim", None)
        ylim = kwargs.pop("ylim", None)
        is_aw = kwargs.pop("Aw", False)
        waveform = kwargs.pop("waveform", {})
        spectral = kwargs.pop("spectral", dict(xlim=(vmin, vmax)))

        gs = gridspec.GridSpec(2, 3, height_ratios=[1, 3], width_ratios=[3, 1, 0.1])
        gs.update(wspace=0.2)

        fig = plt.figure(figsize=(12, 6))
        fig.subplots_adjust(wspace=0.0001)

        # First subplot (Time Plot)
        ax_1 = fig.add_subplot(gs[0])
        bf.plot(plot_type="waveform", ax=ax_1, overlay=True)
        ax_1.set(**waveform)
        ax_1.legend().set_visible(False)
        ax_1.set(xlabel="", title="")

        # Second subplot (STFT Plot)
        ax_2 = fig.add_subplot(gs[3], sharex=ax_1)
        stft_ch = bf.stft()
        if is_aw:
            unit = "dBA"
            channel_data = stft_ch.dBA
        else:
            unit = "dB"
            channel_data = stft_ch.dB
        if channel_data.ndim == 3:
            channel_data = channel_data[0]
        # Get the maximum value of the data and round it to a convenient value
        if vmax is None:
            data_max = np.nanmax(channel_data)
            # Round to a convenient number with increments of 10, 5, or 2
            for step in [10, 5, 2]:
                rounded_max = np.ceil(data_max / step) * step
                if rounded_max >= data_max:
                    vmax = rounded_max
                    vmin = vmax - 180
                    break
        img = display.specshow(
            data=channel_data,
            sr=bf.sampling_rate,
            hop_length=stft_ch.hop_length,
            n_fft=stft_ch.n_fft,
            win_length=stft_ch.win_length,
            x_axis="time",
            y_axis="linear",
            ax=ax_2,
            fmin=fmin,
            fmax=fmax,
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
        )
        ax_2.set(xlim=xlim, ylim=ylim)

        # Third subplot
        ax_3 = fig.add_subplot(gs[1])
        ax_3.axis("off")

        # Fourth subplot (Welch Plot)
        ax_4 = fig.add_subplot(gs[4], sharey=ax_2)
        welch_ch = bf.welch()
        if is_aw:
            unit = "dBA"
            data_db = welch_ch.dBA
        else:
            unit = "dB"
            data_db = welch_ch.dB
        ax_4.plot(data_db.T, welch_ch.freqs.T)
        ax_4.grid(True)
        ax_4.set(xlabel=f"Spectrum level [{unit}]", **spectral)

        cbar = fig.colorbar(img, ax=ax_4, format="%+2.0f")
        cbar.set_label(unit)
        fig.suptitle(title or bf.label or "Channel Data")

        return _return_axes_iterator(fig.axes)
Attributes
name = 'describe' class-attribute instance-attribute
Functions
channel_plot(x, y, ax, **kwargs)

Implementation of channel plotting

Source code in wandas/visualization/plotting.py
557
558
559
def channel_plot(self, x: Any, y: Any, ax: "Axes", **kwargs: Any) -> None:
    """Implementation of channel plotting"""
    pass  # This method is not used for describe plot
plot(bf, ax=None, title=None, overlay=False, **kwargs)

Implementation of describe method for visualizing ChannelFrame data

Source code in wandas/visualization/plotting.py
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
def plot(
    self,
    bf: "ChannelFrame",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    """Implementation of describe method for visualizing ChannelFrame data"""

    fmin = kwargs.pop("fmin", 0)
    fmax = kwargs.pop("fmax", None)
    cmap = kwargs.pop("cmap", "jet")
    vmin = kwargs.pop("vmin", None)
    vmax = kwargs.pop("vmax", None)
    xlim = kwargs.pop("xlim", None)
    ylim = kwargs.pop("ylim", None)
    is_aw = kwargs.pop("Aw", False)
    waveform = kwargs.pop("waveform", {})
    spectral = kwargs.pop("spectral", dict(xlim=(vmin, vmax)))

    gs = gridspec.GridSpec(2, 3, height_ratios=[1, 3], width_ratios=[3, 1, 0.1])
    gs.update(wspace=0.2)

    fig = plt.figure(figsize=(12, 6))
    fig.subplots_adjust(wspace=0.0001)

    # First subplot (Time Plot)
    ax_1 = fig.add_subplot(gs[0])
    bf.plot(plot_type="waveform", ax=ax_1, overlay=True)
    ax_1.set(**waveform)
    ax_1.legend().set_visible(False)
    ax_1.set(xlabel="", title="")

    # Second subplot (STFT Plot)
    ax_2 = fig.add_subplot(gs[3], sharex=ax_1)
    stft_ch = bf.stft()
    if is_aw:
        unit = "dBA"
        channel_data = stft_ch.dBA
    else:
        unit = "dB"
        channel_data = stft_ch.dB
    if channel_data.ndim == 3:
        channel_data = channel_data[0]
    # Get the maximum value of the data and round it to a convenient value
    if vmax is None:
        data_max = np.nanmax(channel_data)
        # Round to a convenient number with increments of 10, 5, or 2
        for step in [10, 5, 2]:
            rounded_max = np.ceil(data_max / step) * step
            if rounded_max >= data_max:
                vmax = rounded_max
                vmin = vmax - 180
                break
    img = display.specshow(
        data=channel_data,
        sr=bf.sampling_rate,
        hop_length=stft_ch.hop_length,
        n_fft=stft_ch.n_fft,
        win_length=stft_ch.win_length,
        x_axis="time",
        y_axis="linear",
        ax=ax_2,
        fmin=fmin,
        fmax=fmax,
        cmap=cmap,
        vmin=vmin,
        vmax=vmax,
    )
    ax_2.set(xlim=xlim, ylim=ylim)

    # Third subplot
    ax_3 = fig.add_subplot(gs[1])
    ax_3.axis("off")

    # Fourth subplot (Welch Plot)
    ax_4 = fig.add_subplot(gs[4], sharey=ax_2)
    welch_ch = bf.welch()
    if is_aw:
        unit = "dBA"
        data_db = welch_ch.dBA
    else:
        unit = "dB"
        data_db = welch_ch.dB
    ax_4.plot(data_db.T, welch_ch.freqs.T)
    ax_4.grid(True)
    ax_4.set(xlabel=f"Spectrum level [{unit}]", **spectral)

    cbar = fig.colorbar(img, ax=ax_4, format="%+2.0f")
    cbar.set_label(unit)
    fig.suptitle(title or bf.label or "Channel Data")

    return _return_axes_iterator(fig.axes)
MatrixPlotStrategy

Bases: PlotStrategy['SpectralFrame']

Strategy for displaying relationships between channels in matrix format

Source code in wandas/visualization/plotting.py
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
class MatrixPlotStrategy(PlotStrategy["SpectralFrame"]):
    """Strategy for displaying relationships between channels in matrix format"""

    name = "matrix"

    def channel_plot(
        self,
        x: Any,
        y: Any,
        ax: "Axes",
        title: str | None = None,
        ylabel: str = "",
        xlabel: str = "Frequency [Hz]",
        alpha: float = 0,
        **kwargs: Any,
    ) -> None:
        ax.plot(x, y, **kwargs)
        ax.grid(True)
        ax.set_xlabel(xlabel)
        ax.set_ylabel(ylabel)
        ax.set_title(title or "")

    def plot(
        self,
        bf: "SpectralFrame",
        ax: Optional["Axes"] = None,
        title: str | None = None,
        overlay: bool = False,
        **kwargs: Any,
    ) -> Axes | Iterator[Axes]:
        kwargs = kwargs or {}
        is_aw = kwargs.pop("Aw", False)
        if (
            len(bf.operation_history) > 0
            and bf.operation_history[-1]["operation"] == "coherence"
        ):
            unit = ""
            data = bf.magnitude
            ylabel = kwargs.pop("ylabel", "coherence")
        else:
            if is_aw:
                unit = "dBA"
                data = bf.dBA
            else:
                unit = "dB"
                data = bf.dB
            ylabel = kwargs.pop("ylabel", f"Spectrum level [{unit}]")

        data = _reshape_to_2d(data)

        xlabel = kwargs.pop("xlabel", "Frequency [Hz]")
        alpha = kwargs.pop("alpha", 1)
        plot_kwargs = filter_kwargs(Line2D, kwargs, strict_mode=True)
        ax_set = filter_kwargs(Axes.set, kwargs, strict_mode=True)
        num_channels = bf.n_channels
        # If an Axes is provided, prefer drawing into it (treat as overlay)
        if ax is not None:
            overlay = True
        if overlay:
            if ax is None:
                fig, ax = plt.subplots(1, 1, figsize=(6, 6))
            else:
                fig = ax.figure
            self.channel_plot(
                bf.freqs,
                data.T,
                ax,  # ここで必ずAxes型
                title=title or bf.label or "Spectral Data",
                ylabel=ylabel,
                xlabel=xlabel,
                alpha=alpha,
                **plot_kwargs,
            )
            ax.set(**ax_set)
            if fig is not None:
                fig.suptitle(title or bf.label or "Spectral Data")
            if ax.figure != fig:  # Only show if we created the figure
                plt.tight_layout()
                plt.show()
            return ax
        else:
            num_rows = int(np.ceil(np.sqrt(num_channels)))
            fig, axs = plt.subplots(
                num_rows,
                num_rows,
                figsize=(3 * num_rows, 3 * num_rows),
                sharex=True,
                sharey=True,
            )
            if isinstance(axs, np.ndarray):
                axes_list = axs.flatten().tolist()
            elif isinstance(axs, list):
                import itertools

                axes_list = list(itertools.chain.from_iterable(axs))
            else:
                axes_list = [axs]
            for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
                self.channel_plot(
                    bf.freqs,
                    channel_data,
                    ax_i,
                    title=ch_meta.label,
                    ylabel=ylabel,
                    xlabel=xlabel,
                    alpha=alpha,
                    **plot_kwargs,
                )
                ax_i.set(**ax_set)
            fig.suptitle(title or bf.label or "Spectral Data")
            plt.tight_layout()
            plt.show()
            return _return_axes_iterator(fig.axes)

        raise NotImplementedError()
Attributes
name = 'matrix' class-attribute instance-attribute
Functions
channel_plot(x, y, ax, title=None, ylabel='', xlabel='Frequency [Hz]', alpha=0, **kwargs)
Source code in wandas/visualization/plotting.py
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
def channel_plot(
    self,
    x: Any,
    y: Any,
    ax: "Axes",
    title: str | None = None,
    ylabel: str = "",
    xlabel: str = "Frequency [Hz]",
    alpha: float = 0,
    **kwargs: Any,
) -> None:
    ax.plot(x, y, **kwargs)
    ax.grid(True)
    ax.set_xlabel(xlabel)
    ax.set_ylabel(ylabel)
    ax.set_title(title or "")
plot(bf, ax=None, title=None, overlay=False, **kwargs)
Source code in wandas/visualization/plotting.py
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
def plot(
    self,
    bf: "SpectralFrame",
    ax: Optional["Axes"] = None,
    title: str | None = None,
    overlay: bool = False,
    **kwargs: Any,
) -> Axes | Iterator[Axes]:
    kwargs = kwargs or {}
    is_aw = kwargs.pop("Aw", False)
    if (
        len(bf.operation_history) > 0
        and bf.operation_history[-1]["operation"] == "coherence"
    ):
        unit = ""
        data = bf.magnitude
        ylabel = kwargs.pop("ylabel", "coherence")
    else:
        if is_aw:
            unit = "dBA"
            data = bf.dBA
        else:
            unit = "dB"
            data = bf.dB
        ylabel = kwargs.pop("ylabel", f"Spectrum level [{unit}]")

    data = _reshape_to_2d(data)

    xlabel = kwargs.pop("xlabel", "Frequency [Hz]")
    alpha = kwargs.pop("alpha", 1)
    plot_kwargs = filter_kwargs(Line2D, kwargs, strict_mode=True)
    ax_set = filter_kwargs(Axes.set, kwargs, strict_mode=True)
    num_channels = bf.n_channels
    # If an Axes is provided, prefer drawing into it (treat as overlay)
    if ax is not None:
        overlay = True
    if overlay:
        if ax is None:
            fig, ax = plt.subplots(1, 1, figsize=(6, 6))
        else:
            fig = ax.figure
        self.channel_plot(
            bf.freqs,
            data.T,
            ax,  # ここで必ずAxes型
            title=title or bf.label or "Spectral Data",
            ylabel=ylabel,
            xlabel=xlabel,
            alpha=alpha,
            **plot_kwargs,
        )
        ax.set(**ax_set)
        if fig is not None:
            fig.suptitle(title or bf.label or "Spectral Data")
        if ax.figure != fig:  # Only show if we created the figure
            plt.tight_layout()
            plt.show()
        return ax
    else:
        num_rows = int(np.ceil(np.sqrt(num_channels)))
        fig, axs = plt.subplots(
            num_rows,
            num_rows,
            figsize=(3 * num_rows, 3 * num_rows),
            sharex=True,
            sharey=True,
        )
        if isinstance(axs, np.ndarray):
            axes_list = axs.flatten().tolist()
        elif isinstance(axs, list):
            import itertools

            axes_list = list(itertools.chain.from_iterable(axs))
        else:
            axes_list = [axs]
        for ax_i, channel_data, ch_meta in zip(axes_list, data, bf.channels):
            self.channel_plot(
                bf.freqs,
                channel_data,
                ax_i,
                title=ch_meta.label,
                ylabel=ylabel,
                xlabel=xlabel,
                alpha=alpha,
                **plot_kwargs,
            )
            ax_i.set(**ax_set)
        fig.suptitle(title or bf.label or "Spectral Data")
        plt.tight_layout()
        plt.show()
        return _return_axes_iterator(fig.axes)

    raise NotImplementedError()
Functions
register_plot_strategy(strategy_cls)

Register a new plot strategy from a class

Source code in wandas/visualization/plotting.py
778
779
780
781
782
783
784
def register_plot_strategy(strategy_cls: type) -> None:
    """Register a new plot strategy from a class"""
    if not issubclass(strategy_cls, PlotStrategy):
        raise TypeError("Strategy class must inherit from PlotStrategy.")
    if inspect.isabstract(strategy_cls):
        raise TypeError("Cannot register abstract PlotStrategy class.")
    _plot_strategies[strategy_cls.name] = strategy_cls
get_plot_strategy(name)

Get plot strategy by name

Source code in wandas/visualization/plotting.py
793
794
795
796
797
def get_plot_strategy(name: str) -> type[PlotStrategy[Any]]:
    """Get plot strategy by name"""
    if name not in _plot_strategies:
        raise ValueError(f"Unknown plot type: {name}")
    return _plot_strategies[name]
create_operation(name, **params)

Create operation instance from operation name and parameters

Source code in wandas/visualization/plotting.py
800
801
802
803
def create_operation(name: str, **params: Any) -> PlotStrategy[Any]:
    """Create operation instance from operation name and parameters"""
    operation_class = get_plot_strategy(name)
    return operation_class(**params)

types

Type definitions for visualization parameters.

Classes
WaveformConfig

Bases: TypedDict

Configuration for waveform plot in describe view.

This corresponds to the time-domain plot shown at the top of the describe view.

Source code in wandas/visualization/types.py
 6
 7
 8
 9
10
11
12
13
14
15
16
class WaveformConfig(TypedDict, total=False):
    """Configuration for waveform plot in describe view.

    This corresponds to the time-domain plot shown at the top of the
    describe view.
    """

    xlabel: str
    ylabel: str
    xlim: tuple[float, float]
    ylim: tuple[float, float]
Attributes
xlabel instance-attribute
ylabel instance-attribute
xlim instance-attribute
ylim instance-attribute
SpectralConfig

Bases: TypedDict

Configuration for spectral plot in describe view.

This corresponds to the frequency-domain plot (Welch) shown on the right side.

Source code in wandas/visualization/types.py
19
20
21
22
23
24
25
26
27
28
29
class SpectralConfig(TypedDict, total=False):
    """Configuration for spectral plot in describe view.

    This corresponds to the frequency-domain plot (Welch) shown on the
    right side.
    """

    xlabel: str
    ylabel: str
    xlim: tuple[float, float]
    ylim: tuple[float, float]
Attributes
xlabel instance-attribute
ylabel instance-attribute
xlim instance-attribute
ylim instance-attribute
DescribeParams

Bases: TypedDict

Parameters for the describe visualization method.

This visualization creates a comprehensive view with three plots: 1. Time-domain waveform (top) 2. Spectrogram (bottom-left) 3. Frequency spectrum via Welch method (bottom-right)

Attributes:

Name Type Description
fmin float

Minimum frequency to display in the spectrogram (Hz). Default: 0

fmax float | None

Maximum frequency to display in the spectrogram (Hz). Default: Nyquist frequency

cmap str

Colormap for the spectrogram. Default: 'jet'

vmin float | None

Minimum value for spectrogram color scale (dB). Auto-calculated if None.

vmax float | None

Maximum value for spectrogram color scale (dB). Auto-calculated if None.

xlim tuple[float, float] | None

Time axis limits (seconds) for all time-based plots.

ylim tuple[float, float] | None

Frequency axis limits (Hz) for frequency-based plots.

Aw bool

Apply A-weighting to the frequency analysis. Default: False

waveform WaveformConfig

Additional configuration dict for waveform subplot.

spectral SpectralConfig

Additional configuration dict for spectral subplot.

normalize bool

Normalize audio data for playback. Default: True

is_close bool

Close the figure after displaying. Default: True

Deprecated (for backward compatibility): axis_config: Old configuration format. Use specific parameters instead. cbar_config: Old colorbar configuration. Use vmin/vmax instead.

Examples:

>>> cf = ChannelFrame.read_wav("audio.wav")
>>> # Basic usage
>>> cf.describe()
>>>
>>> # Custom frequency range
>>> cf.describe(fmin=100, fmax=5000)
>>>
>>> # Custom color scale
>>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
>>>
>>> # A-weighted analysis
>>> cf.describe(Aw=True)
>>>
>>> # Custom time range
>>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
Source code in wandas/visualization/types.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
class DescribeParams(TypedDict, total=False):
    """Parameters for the describe visualization method.

    This visualization creates a comprehensive view with three plots:
    1. Time-domain waveform (top)
    2. Spectrogram (bottom-left)
    3. Frequency spectrum via Welch method (bottom-right)

    Attributes:
        fmin: Minimum frequency to display in the spectrogram (Hz).
            Default: 0
        fmax: Maximum frequency to display in the spectrogram (Hz).
            Default: Nyquist frequency
        cmap: Colormap for the spectrogram. Default: 'jet'
        vmin: Minimum value for spectrogram color scale (dB).
            Auto-calculated if None.
        vmax: Maximum value for spectrogram color scale (dB).
            Auto-calculated if None.
        xlim: Time axis limits (seconds) for all time-based plots.
        ylim: Frequency axis limits (Hz) for frequency-based plots.
        Aw: Apply A-weighting to the frequency analysis. Default: False
        waveform: Additional configuration dict for waveform subplot.
        spectral: Additional configuration dict for spectral subplot.
        normalize: Normalize audio data for playback. Default: True
        is_close: Close the figure after displaying. Default: True

    Deprecated (for backward compatibility):
        axis_config: Old configuration format.
            Use specific parameters instead.
        cbar_config: Old colorbar configuration. Use vmin/vmax instead.

    Examples:
        >>> cf = ChannelFrame.read_wav("audio.wav")
        >>> # Basic usage
        >>> cf.describe()
        >>>
        >>> # Custom frequency range
        >>> cf.describe(fmin=100, fmax=5000)
        >>>
        >>> # Custom color scale
        >>> cf.describe(vmin=-80, vmax=-20, cmap="viridis")
        >>>
        >>> # A-weighted analysis
        >>> cf.describe(Aw=True)
        >>>
        >>> # Custom time range
        >>> cf.describe(xlim=(0, 5))  # Show first 5 seconds
    """

    # Spectrogram parameters
    fmin: float
    fmax: float | None
    cmap: str
    vmin: float | None
    vmax: float | None

    # Axis limits
    xlim: tuple[float, float] | None
    ylim: tuple[float, float] | None

    # Weighting
    Aw: bool

    # Subplot configurations
    waveform: WaveformConfig
    spectral: SpectralConfig

    # Display options
    normalize: bool
    is_close: bool

    # Deprecated (backward compatibility)
    axis_config: dict[str, Any]
    cbar_config: dict[str, Any]
Attributes
fmin instance-attribute
fmax instance-attribute
cmap instance-attribute
vmin instance-attribute
vmax instance-attribute
xlim instance-attribute
ylim instance-attribute
Aw instance-attribute
waveform instance-attribute
spectral instance-attribute
normalize instance-attribute
is_close instance-attribute
axis_config instance-attribute
cbar_config instance-attribute

データセットモジュール

データセットモジュールはサンプルデータとデータセット機能を提供します。

wandas.datasets

Modules

sample_data

Attributes
Functions
load_sample_signal(frequency=5.0, sampling_rate=100, duration=1.0)

Generate a sample sine wave signal.

Parameters

frequency : float, default=5.0 Frequency of the signal in Hz. sampling_rate : int, default=100 Sampling rate in Hz. duration : float, default=1.0 Duration of the signal in seconds.

Returns

NDArrayReal Signal data as a NumPy array.

Source code in wandas/datasets/sample_data.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def load_sample_signal(
    frequency: float = 5.0, sampling_rate: int = 100, duration: float = 1.0
) -> NDArrayReal:
    """
    Generate a sample sine wave signal.

    Parameters
    ----------
    frequency : float, default=5.0
        Frequency of the signal in Hz.
    sampling_rate : int, default=100
        Sampling rate in Hz.
    duration : float, default=1.0
        Duration of the signal in seconds.

    Returns
    -------
    NDArrayReal
        Signal data as a NumPy array.
    """
    num_samples = int(sampling_rate * duration)
    t = np.arange(num_samples) / sampling_rate
    signal: NDArrayReal = np.sin(2 * np.pi * frequency * t, dtype=np.float64)
    return signal